In RequestCoalescer.Do, the primary path calls fn() before storing the
response, deleting the inflight key and calling wg.Done(). If fn() panics,
all three steps are skipped: the sync.Map entry leaks and every coalesced
waiter remains blocked on wg.Wait() forever (permanent deadlock for that
key). Route release through a defer so waiters are always released and the
entry cleared, even on panic.
Test: TestRequestCoalescer_PanicSafety (a panicking primary must not poison
the key for subsequent calls).
IsRetryable and GetStatusCode used a direct type assertion, so a ProxyError
wrapped with %w was never detected (status fell back to 500, retryable to
false). Use errors.As, which dereferences the existing Unwrap chain and
matches the file's own Unwrap method.
Test: Test_IsRetryable_GetStatusCode_Wrapped (wrapped error preserves both
flags).
Note: these helpers currently have no production callers (test-only
surface), so this is a correctness hardening of the error API.
Two defects in the bidirectional proxy:
1. Goroutine/socket leak on client disconnect. proxyClientToBackend and
proxyBackendToClient each block on their own Read; when the client drops,
the client-direction goroutine returns but the backend-direction goroutine
stays blocked on backend.ReadMessage, so wg.Wait() never returns and the
goroutine plus backend socket leak until the backend independently closes
or sends. Each direction now closes the opposite connection on exit,
unblocking the peer.
2. Backend->client messages were unbounded. client.SetReadLimit only bound
what the client could send; a large or misbehaving backend could push
arbitrarily large frames through. backend.SetReadLimit now bounds it.
Tests: TestWebSocketProxy_TeardownOnClientDisconnect (backend must be closed
after client disconnect), TestWebSocketProxy_BackendReadLimit (connection
torn down when backend exceeds the limit).
On signal, main waited on wg for StartHTTPProxy's server.Listen, which never
returns because nothing called server.Shutdown(); it timed out after 10s and
process-exited, abruptly killing in-flight GraphQL requests.
Expose the proxy Fiber app and register an http-proxy Shutdown component so
server.Listen returns after Shutdown, letting connections drain. Guard the
shared app handle with a mutex (written by the StartHTTPProxy goroutine, read
by the shutdown component). Verified with build, vet, full test and full
race suite.
Clear() used FlushDB, wiping every key in the selected Redis DB even if
that database is shared with other tenants. Switch to a prefix-scoped
SCAN+DEL so only this cache's keys (prefixed) are removed, matching the
memory cache's Clear semantics.
GetMemoryUsage made an Info() round-trip purely to return 0, stalling
the admin dashboard when Redis is slow; return 0 without the call.
UpdateConfig writes tokensPerSecond/maxTokens/enabled as plain fields while
the refill goroutine and AllowRetry readers touch them concurrently. Guard
all reads/writes of the mutable config with an RWMutex.
Adds TestRetryBudget_ConcurrentUpdateConfig, which races UpdateConfig
against AllowRetry/GetStats and is verified clean under -race.
Also drop the package-level min() that shadowed the Go 1.25 builtin.
doProxyRequestWithTimeout returns an error for every non-200 status and
executeProxyAttempt returned it as a plain, retryable error, so 4xx
client errors (400/404/405/...) were retried with exponential backoff
and the 4xx branch of isRetryableStatusCode was dead code.
Additionally, retry-go's finite-attempt loop only consults the custom
RetryIf, which overrode the default IsRecoverable gate, so retry.
Unrecoverable errors (non-retryable 4xx AND timeouts) were still
retried. Honor IsRecoverable in RetryIf so these never retry.
Now 5xx/429 are retried, other 4xx and timeouts fail fast.
Adds Test_proxyRetryPolicyByStatusCode locking in the policy.
The test hit live external endpoints (telegram-bot.app, google.com) and
failed once telegram-bot.app changed /v1/graphql to return 405. Replace
real hosts with local ok (200) and err (404) servers, preserving the
query->read-only / mutation->write routing semantics under test.
- Document WebSocket-streamed live metrics (2s) with 5s polling fallback, the overview KPIs and live charts, and the cluster-view toggle (aggregated vs this-node, disabled in single-node).
- Complete the API endpoint list (stats, cache, ws/stats, cluster endpoints, cache/clear) to match the registered routes. - Regenerate static/admin-dashboard.png for the redesigned dashboard.
Fixes the stale 'auto-refreshes every 5 seconds' claim.
BREAKING CHANGE: upgrades the HTTP framework from gofiber/fiber/v2 to gofiber/fiber/v3, and gofiber/websocket/v2 to gofiber/contrib/v3/websocket.
- Handlers now take fiber.Ctx (interface) instead of *fiber.Ctx. - DisableStartupMessage moved from fiber.Config to fiber.ListenConfig on the proxy/api/monitoring Listen calls. - cors AllowOrigins is now []string; c.BodyParser -> c.Bind().Body(). - app.Test takes fiber.TestConfig instead of an int timeout (tests updated). - WebSocket swapped to contrib/v3/websocket (same Conn/New/Config/IsCloseError/IsWebSocketUpgrade API; Conn.Query preserved). - Adopts fasthttp v1.71.0 (pulled by fiber v3), resolving the v1.71 Host-header enforcement that broke the prior pinned-v1.69 workaround.
fix(connpool): the background keep-alive goroutine no longer reads the mutable global cfg (captures HostGraphQL/HealthcheckGraphQL at pool creation). Under v3 timing it raced with parseConfig/test cfg reassignment; go test -race ./... is now clean (0 data races).
test: make the circuit-breaker cache-fallback and request-coalescing integration tests deterministic (cache TTL outlives the retry loop; coalescing asserts against a measured single-request baseline instead of a hard-coded count); drop the flaky timing-ratio assertion in TestTimingAttackResistance (constant-time guarantee comes from subtle.ConstantTimeCompare, not wall-clock timing).
Verified: go build, go vet, golangci-lint (0 issues), go test -race ./... (all packages pass, 0 races), govulncheck (no vulnerabilities). App + dashboard + WebSocket verified live under v3 in single-node and cluster modes.
Bump golang.org/x/net v0.52.0 -> v0.56.0, fixing GO-2026-5026 (idna Punycode label rejection) and GO-2026-4918 (HTTP/2 transport infinite loop). govulncheck now reports no vulnerabilities. Also bumps x/sync, x/sys, x/term, x/text, otel v1.43->v1.44, grpc v1.80->v1.81 and genproto.
Hold github.com/valyala/fasthttp at v1.69.0: v1.71.0 changes request handling such that the API auth/integration security suites fail (app.Test errors on benign requests), violating the behaviour-preserving upgrade rule. fasthttp had no flagged vulnerability, so pinning it back loses no security fix. Adopting v1.71.0 needs separate investigation.
Verified: go build, go vet, golangci-lint, go test -race ./... all pass; govulncheck clean.
Starting with ENABLE_CIRCUIT_BREAKER=true panicked at boot: parseConfig -> initCircuitBreaker registers a metrics gauge, but cfg.Monitoring is assigned later by StartMonitoringServer, so the registration dereferenced a nil *MetricsSetup.
Add nil-receiver guards to the MetricsSetup registration methods (RegisterMetricsGauge/GaugeFunc/Counter, RegisterFloatCounter, RegisterMetricsSummary/Histogram), matching their existing 'return a dummy to prevent panics' contract. The guard is a no-op for non-nil receivers, so existing callers are unaffected. The breaker and the admin dashboard (which reads gobreaker state directly) keep working.
Add regression tests for the nil-receiver methods and for initCircuitBreaker with nil monitoring.
RecordRequest() only ran inside proxyTheRequest, but cache hits return from handleCaching before reaching it. As a result the dashboard's current RPS read 0 whenever traffic was served from cache (e.g. a repeated query at 100% hit rate). Record the request on the cache-hit branch too; misses and proxied/GET requests still record in proxyTheRequest, so there is no double counting.
Verified: 100% cache-hit load now reports ~16-20k req/s instead of a flat 0.
Replace the AI-slop visual (indigo->purple gradient, glassmorphism, uniform rounded cards, hover-lift) with a deliberate warm-paper 'instrument panel': monospace tabular telemetry, GraphQL brand magenta accent, flat hairline cards, real type hierarchy, WCAG-AA contrast, focus-visible, prefers-reduced-motion and aria-live status. No new font/CDN deps.
Make the cluster-view toggle functional: the WebSocket stats stream now honours a ?view=local|cluster query param, so toggling streams this-node-only vs aggregated metrics instead of being cosmetic (the stream previously always sent cluster-aware data when an aggregator existed). The toggle is always visible -- disabled with a 'single node' hint when Redis cluster mode is off -- so it is discoverable.
Fix cluster-mode rendering bugs: derive backend health from healthy/total instances (health card no longer stuck on 'Loading'), show 'n/a' for Redis memory instead of a misleading 0 MB, render aggregated CB/retry/coalescing/connections on the polling path too, and surface the proxy version and backend-health detail.
Tests assert the served dashboard against stable markers instead of the cosmetic page title.
The repo's go.mod has required go 1.25.0 since the perf+coverage pass,
but the release workflow still pinned setup-go to 1.24 — the latest
1.24.X tool refuses to compile a 1.25 module with GOTOOLCHAIN=local,
breaking auto-release on every push.
The released v0.45.1 binary shipped with the source default
appVersion="dev" because .goreleaser.yaml had ldflags="-s -w" only,
so every startup ping was rejected by the receiver with HTTP 400
(invalid version: regex requires leading digit).
Two-layer fix:
1. .goreleaser.yaml now passes -X main.appVersion={{.Version}} so
goreleaser-built binaries report the actual release version.
2. Switch to telemetry.SendForModule which prefers
debug.ReadBuildInfo Main/Deps when available, falling back to
appVersion. This means `go install github.com/lukaszraczylo/
[email protected]` users also get correct versions
without relying on the ldflag.
Bumps oss-telemetry to v0.2.1 for SendForModule.
Discloses the single anonymous adoption ping sent on startup and points
users to the upstream README section for full opt-out instructions
instead of duplicating the table here.
Send a single fire-and-forget ping at startup to help track adoption
and version spread. No persistent identifiers are collected.
Adds main.appVersion var (defaulting to "dev"); wire ldflags
(-X main.appVersion=$VERSION) to populate it at release time.
Opt out via any of:
DO_NOT_TRACK=1
OSS_TELEMETRY_DISABLED=1
GRAPHQL_MONITORING_PROXY_DISABLE_TELEMETRY=1
* chore(security,refactor): extract sanitization and improve code quality
- [x] Extract sanitization functions to dedicated sanitization.go module
- [x] Add comprehensive golangci-lint v2 configuration with security rules
- [x] Replace interface{} with any type throughout codebase
- [x] Add admin API authentication security warning
- [x] Extract WebSocket and stats streaming constants
- [x] Add best-effort error handling comments for resource cleanup
- [x] Expand sensitive field patterns for improved PII redaction
- [x] Simplify safety checks and remove redundant nil validations
- [x] Improve test coverage for password field redaction patterns
* refactor: replace interface{} with any type alias
- [x] Replace all `map[string]interface{}` with `map[string]any`
- [x] Replace all `interface{}` with `any` in function signatures and type definitions
- [x] Update sync.Pool New function returns from `interface{}` to `any`
- [x] Add package documentation comments to 8 package files
- [x] Update type assertions and casts to work with `any` type
Under high concurrency, the sync.Pool pattern was creating a race condition
where the same result pointer was being reused by multiple concurrent requests.
The bug:
- parseGraphQLQuery() returns a pointer to 'res' from the pool
- The defer statement returns 'res' back to the pool on function exit
- While the caller is still using the returned pointer, another concurrent
request could get the SAME pointer from the pool and modify it
This caused mutations to randomly get the wrong activeEndpoint value:
- Request A: mutation parsed → activeEndpoint set to :8080 (write)
- Request A: returns pointer to result
- Request A: defer runs → result returned to pool
- Request B: gets SAME pointer from pool
- Request B: query parsed → activeEndpoint overwritten to :8088 (read-only)
- Request A: still holding pointer, now sees :8088 instead of :8080!
- Result: mutation routed to read-only endpoint → database write failure
The fix:
Create a copy of the result before returning, so the pooled object can be
safely reused without affecting the returned value.
BUG FIX: The endpoint routing logic was inside the loop that
processes all GraphQL definitions. This caused mutations to be incorrectly
routed to read-only endpoints when followed by other definitions (queries,
fragments, etc).
The bug manifested as: mutations → read-only Hasura → read-only pooler →
PostgreSQL replica → "cannot set transaction read-write mode during recovery"
Changes:
- Move endpoint routing logic AFTER the definition processing loop
- Ensures mutations are ALWAYS routed to write endpoint regardless of
subsequent definitions in the document
- Add 3 comprehensive regression tests covering:
1. Mutation with multiple operations
2. Mutation followed by fragment
3. Complex main-bot style mutation document
Tests: All pass including new regression tests
Impact: Fixes database write failures in main-bot and other services