Commit Graph
503 Commits
Author SHA1 Message Date
lukaszraczyloandGitHub aa5167c42b Update go.mod and go.sum (#89) v3.1.11 2026-09-15 04:02:28 +01:00
lukaszraczyloandGitHub e2d151e9b7 Update go.mod and go.sum (#88) v3.1.9 2026-09-13 04:02:14 +01:00
lukaszraczylo 02d9796adb fix(deps): update dependencies and allow loopback upstreams on fiber v3.5.0
Update all direct and indirect Go dependencies. Notable bumps are
gofiber/fiber v3.4.0 to v3.5.0, gofiber/contrib websocket v1.2.0 to
v1.2.6, fasthttp v1.72.0 to v1.74.0, gookit/goutil v0.7.6 to v0.8.0,
go-redis v9.20.1 to v9.22.0, pgx v5.10.0 to v5.11.0, OpenTelemetry
v1.44.0 to v1.46.0, grpc v1.82.1 to v1.83.2 and testify v1.11.1 to
v1.12.1. The go directive moves to 1.26.0 because websocket v1.2.6
requires it.

fiber v3.5.0 adds an SSRF dial guard to clients registered with
proxy.WithClient. The guard rejects loopback and private upstreams by
default. This proxy gets its upstream from operator configuration, and
sidecar deployments point it at localhost. Set
proxy.SecurityPolicy{AllowPrivateIPs: true} so these upstreams keep
working. This change also unblocks the nightly autoupdate workflow,
which failed on this guard.

Claude-Session: https://claude.ai/code/session_01Fu2wXqGUrfVBi5aLafKk8U
v3.1.7
2026-09-10 15:18:31 +01:00
lukaszraczylo 6e571f8fa0 fix(deps): bump gofiber/fiber to v3.4.0 for HSTS advisory fix (GHSA-gv83-gqw6-9j2c)
Fiber v3.3.0 and earlier never set the HSTS header in the helmet middleware (GHSA-gv83-gqw6-9j2c, GO-2026-5912). Version v3.4.0 fixes that and adds an open-redirect fix and X-Forwarded-For hardening. Version v3.4.0 does NOT contain the loopback SSRF block that v3.5.0 added to the proxy middleware, so the 127.0.0.1 backend behaviour is unchanged. The full race test suite, including the loopback proxy tests, passes. Transitive bumps: fasthttp v1.71.0 to v1.72.0, klauspost/compress to v1.19.0.
v3.1.5
2026-08-17 19:47:30 +01:00
lukaszraczylo 4a68dcb00a fix(build): pin Go toolchain to 1.26.6 for standard-library CVE fixes
Builds used the go1.26.5 toolchain, whose standard library contained seven vulnerabilities that govulncheck reported as reachable (GO-2026-6218, GO-2026-6091, GO-2026-6090, GO-2026-6089, GO-2026-6088, GO-2026-5972, GO-2026-5026). This change pins the module toolchain to go1.26.6 and sets the CI Go version to 1.26.6, so every build compiles against the patched standard library. After the bump govulncheck reports zero vulnerabilities affecting the code. The go language directive stays at 1.25.0, so no language behaviour changes.
2026-08-17 19:45:26 +01:00
lukaszraczylo 3bce659203 fix(deps): bump klauspost/compress to v1.18.7 for s2 out-of-bounds read (GHSA-259r-337f-4rfw)
The indirect dependency github.com/klauspost/compress v1.18.6 contained an out-of-bounds read in the s2 package (GO-2026-5841). The proxy pulls this module in through valyala/fasthttp. govulncheck reports the vulnerable path as unreachable, so the real risk is low, but v1.18.7 removes the defect. The bump is a patch release with no API change.
v3.1.1
2026-08-17 19:30:07 +01:00
lukaszraczylo 8258b3d99a fix(auth): verify JWT signature to prevent cache-key forgery (GHSA-9gqw-h2rw-44wv)
The proxy derived per-user GraphQL cache keys from JWT claims without
verifying the token signature (CWE-345). A remote attacker could forge a
token that carried a victim's claim value and read the victim's cached
response.

This change adds opt-in signature verification behind JWT_VERIFY_SIGNATURE.
The flag defaults to false, so an upgrade without a config change keeps the
previous delegate-to-upstream behavior byte for byte.

When verification is on, the operator must set exactly one key source:
JWT_SECRET, JWT_PUBLIC_KEY, or JWT_JWKS_URL. The verifier pins the accepted
algorithms to the key family through WithValidMethods and a type-switched
keyfunc, and it refuses alg=none. This closes the algorithm-confusion class
of attack. A present but invalid token now returns 401. A missing
Authorization header stays anonymous.

Startup fails loudly when verification is on and the key source is absent or
unreadable, so the proxy can never boot half-configured.
2026-08-17 13:51:00 +01:00
99f0c844f0 fix(tests): configurable retry delays and circuit backoff overflow
Three bugs fixed:

1. Test suite timeout: add Client.RetryBaseDelayMs/RetryMaxDelayMs config
   fields (env CLIENT_RETRY_BASE_DELAY_MS/CLIENT_RETRY_MAX_DELAY_MS,
   defaults 500ms/10s). Tests override to 10ms so retry exhaustion
   completes in ms instead of ~25s per request. Suite runs in ~35s.

2. effectiveCircuitBackoff float overflow: on amd64, float64(+Inf) cast
   to int64 gives MinInt64 not MaxInt64, so the < base guard fired and
   returned base instead of the 24h sanity ceiling. Fix: check
   math.IsInf/math.IsNaN before the cast.

3. TestStartHTTPProxy_ErrorHandlerConfigured bypassed the TestSuite
   retry-delay override; now sets/restores the fields explicitly.

Co-authored-by: lukaszraczylo <[email protected]>
2026-08-14 16:00:50 +00:00
8d1518b961 fix(tests): configurable retry delays and circuit backoff overflow
Three bugs fixed:

1. **Test suite timeout** — The main-package test suite timed out at 120s
   because each failing request triggered 7 retries with exponential
   backoff (500ms base → 10s max ≈ 25.5s per request). Tests that trip
   the circuit breaker (3 requests × 25.5s = 76.5s) plus
   TestStartHTTPProxy_ErrorHandlerConfigured (another 25.5s) pushed
   cumulative time past the limit.

   Fix: add `Client.RetryBaseDelayMs` / `Client.RetryMaxDelayMs` config
   fields (env: CLIENT_RETRY_BASE_DELAY_MS / CLIENT_RETRY_MAX_DELAY_MS,
   defaults 500ms / 10000ms). Tests override these to 10ms so retry
   exhaustion completes in milliseconds. Full suite now runs in ~35s.

2. **effectiveCircuitBackoff float overflow** — On amd64, converting
   `+Inf` (float64) to `time.Duration` (int64) saturates to MinInt64,
   not MaxInt64. The existing "< base" guard then returned `base` instead
   of `maxSaneCircuitBackoff`, breaking the sanity ceiling for an uncapped
   config with a high trip count. Fix: detect `math.IsInf / math.IsNaN`
   before the cast and return the ceiling directly.

3. **TestStartHTTPProxy_ErrorHandlerConfigured** — standalone top-level
   test calling parseConfig() directly, bypassing the TestSuite override.
   Now sets and restores RetryBaseDelayMs/RetryMaxDelayMs explicitly.

Co-authored-by: lukaszraczylo <[email protected]>
2026-08-14 15:58:10 +00:00
lukaszraczylo aaa08dfbb2 docs: ground README and landing page in the actual behavior
An audit against the code found stale defaults, wrong numbers, an
invented error-response example, and capability claims for features that
do not exist (query-complexity limiting, IP-based and per-endpoint rate
limits, config hot-reload).

Correct the env-var defaults, the degraded-retry and keepalive numbers,
and the circuit-open response example. Reframe the cache-key isolation as
a correctness boundary, not an authentication one, because the proxy does
not verify JWT signatures. Document the admin dashboard exposure, the API
key scope, the WebSocket ban and rate-limit checks, and the new
BIND_ADDRESS option.
2026-08-14 13:01:34 +01:00
lukaszraczylo 6171436a99 fix(api): report the real keepalive interval in pool health
The /api/connection-pool/health endpoint hardcoded a keepalive interval
of 15s while the pool actually uses 45s, so it reported a false value.
The endpoint now reads the live keepAliveInterval field. A startup
backoff comment that claimed jitter is also corrected; the backoff has
no jitter.
2026-08-14 13:01:20 +01:00
lukaszraczylo 70b86c779b fix(server): gate WebSocket on ban and rate limit, map errors to status
The WebSocket route skipped the ban and rate-limit checks that the HTTP
path enforced, and returned errors set no custom status, so circuit-open
became HTTP 500. The route now runs the same ban and rate-limit checks
before the upgrade, and a custom error handler maps errors to the
correct status, including 503 for circuit-open. The API and admin
listeners honour the BIND_ADDRESS option.
2026-08-14 11:10:54 +01:00
lukaszraczylo 044fa41744 fix(config): validate env values and clamp unsafe settings
Invalid numeric environment values parsed to zero, an invalid prefixed
value fell through to a stale unprefixed value, and the prefixed boolean
parser rejected on and yes that the unprefixed parser accepted. A
non-positive cache TTL or retry-budget rate could crash or disable a
subsystem.

Parsing now warns and uses the coded default for an invalid value,
prefixed and unprefixed booleans accept the same tokens, and the cache
TTL and retry rate are clamped. A dead per-endpoint circuit config was
removed and a BIND_ADDRESS option was added, defaulting to the current
all-interfaces bind. The pprof server now stops on shutdown.
2026-08-14 11:10:39 +01:00
lukaszraczylo 10e51daab3 feat(circuit): progressive backoff and configurable trip conditions
The circuit breaker ignored the trip-on-timeout, trip-on-4xx, and
trip-on-5xx flags and never cleared its counts, so the failure ratio
became lifetime-cumulative. The backoff-multiplier and max-backoff
settings did nothing. Circuit-open surfaced as HTTP 500.

Failure classification now honours the trip flags, the breaker uses a
rolling count window, and repeated trips extend the open state up to the
configured maximum. A helper maps circuit-open to HTTP 503. The request
body is copied once per request instead of once per retry. Defaults
reproduce the prior behaviour: the breaker is off by default and a
backoff multiplier of one keeps a constant open window.
2026-08-14 11:10:37 +01:00
lukaszraczylo 6751346796 fix(ratelimit): make burst a short-window allowance above the rate
Burst and the sustained rate were compared against the same window, so
a burst above the sustained rate never took effect. Burst is now
measured over a short window on top of the sustained limit. An unset
burst preserves the previous rate-only behaviour.
2026-08-14 11:10:24 +01:00
lukaszraczylo e6a43356c0 fix(metrics): order aggregator shutdown and reset the singleton
Shutdown closed the Redis client while the publisher goroutine could
still use it, so the instance key sometimes lingered in the cluster set,
and the goroutine could read a package global after shutdown returned.
Shutdown now removes the instance key, joins the publisher goroutine,
then closes the client, and clears the singleton so a later
initialisation creates a fresh instance.
2026-08-14 11:10:22 +01:00
lukaszraczylo 2911dd9b1d perf(monitoring): cache hostname and bound the label-key cache
get_metrics_name called os.Hostname on every metrics call and keyed a
never-evicted cache by label values. The hostname is now resolved once
and the cache is keyed by label names, so it cannot grow with high
cardinality values. The metrics listener also honours a graceful
shutdown context.
2026-08-14 11:10:15 +01:00
lukaszraczylo a774f9905b fix(logging): make level and field-name access concurrency-safe
SetFieldName wrote a package-global map with no lock while log read it
from many goroutines, and the level, caller, and time-format setters
mutated fields without synchronisation. A runtime setter call could
crash the process with a concurrent map write.

The scalar fields now use atomics on the hot log path and the global
field-name map uses a read-write mutex. Behaviour for init-time
configuration is unchanged.
2026-08-14 11:10:01 +01:00
lukaszraczylo 159faa68e0 perf(cache): sort-based eviction and consistent non-positive TTL handling
The count-based evictOldest used an O(n squared) selection sort with a
candidate cap over an unordered map range, so it could evict the wrong
entries. It now collects all entries and sorts by expiry, matching the
memory-based evictor.

A non-positive CACHE_TTL behaved differently per backend: the standard
memory cache panicked, Redis stored a permanent key, and the LRU cache
expired entries immediately. Each backend now clamps a non-positive TTL
to a shared default at its Set boundary. The nil-logger fallback in the
Redis wrapper now uses the constructor so it cannot panic.
2026-08-14 11:09:57 +01:00
lukaszraczylo 1cab28b054 fix(shutdown): sequence components into phases so backends close after drain
The manager ran every component concurrently, so on SIGTERM the cache
and connection backends closed while the proxy was still draining
in-flight requests, which then failed with closed clients.

Add an ordered phase to each component. Run phases in ascending order,
components within a phase concurrently, under one timeout budget. Drain
the proxy in an earlier phase than the backend closers.
2026-08-14 01:42:06 +01:00
lukaszraczylo 77447bd353 fix(retry): start refill loop when budget enabled via UpdateConfig
The refill goroutine started only when the budget was enabled at
construction. Enabling it later through UpdateConfig left a bucket that
never refilled.

Start the refill loop from UpdateConfig, guarded by sync.Once so it
starts at most once, sharing the existing shutdown lifecycle.
2026-08-14 01:42:04 +01:00
lukaszraczylo 6226717aad fix(logging): bound JSON log path and mask secrets straddling the cut
The JSON log path re-marshaled redacted output with no size cap, so a
large body was logged in full. An XML-form secret whose closing tag
fell past the truncation cut also leaked its prefix.

Cap the re-marshaled JSON at MaxLogBodySize after redaction. Strip a
trailing unterminated sensitive tag. Fold tag names with a
length-preserving ASCII lower so the cut index stays valid.
2026-08-14 01:41:56 +01:00
lukaszraczylo 95f72b0b1a fix(cache): make memory Clear counter accounting race-safe
Clear reset the counters with StoreInt64(0) after deleting entries, so
a Set racing the reset left a live entry while counters read zero, and
the next Delete drove them permanently negative.

Rewrite Clear to LoadAndDelete each key and subtract the linearized
returned entry, matching the rest of the file. Size the key slice from
the live entry count.
2026-08-14 01:41:43 +01:00
lukaszraczylo 636ddb9d95 fix(cache): escape glob metacharacters and aggregate errors in redis Clear
The SCAN MATCH pattern used the raw prefix, so a prefix containing
Redis glob metacharacters cleared the wrong keys or none. A Del error
also aborted the clear and discarded the remaining batches.

Escape the prefix for MATCH, continue past a per-batch Del error, and
return the joined error. Apply the same escaping to CountQueries.
2026-08-14 01:41:31 +01:00
lukaszraczylo f792de815a fix(logging): bound truncateUTF8 backoff and mask secrets that straddle the cut
truncateUTF8 validated the whole string each iteration. One
invalid byte emptied the log line at quadratic cost. A secret
whose closing quote fell past the truncation cut also leaked its
leading characters, because the redaction pattern needs a
terminator.

Back off over the last rune only, bounded by the maximum rune
width, and trim the partial rune before redaction runs. Strip a
trailing unterminated quoted value after the cut.
2026-08-14 00:01:44 +01:00
lukaszraczylo fcdf76b8e8 fix(coalesce): surface panic error to coalesced waiters
When the primary request panicked, waiters received a nil response
and nil error, which the proxy turned into an empty HTTP 200.

Recover in the deferred release, store an error response for the
waiters, then re-panic so the primary panic semantics are
unchanged. The test gates the panic on the live waiter count, so
every waiter is coalesced before the release.
2026-08-14 00:01:31 +01:00
lukaszraczylo 2c2fe2623f fix(cache): make memory counter accounting lock-free and bound eviction alloc
The Set mutex serialised writes but left Delete, expiry, and
eviction racing the same counters, so double subtraction remained.
Eviction also pre-allocated from the configured maximum, which
could allocate hundreds of megabytes on a near-empty cache.

Use sync.Map.Swap in Set and adjust counters from the linearized
previous value. Size the eviction slice from the live entry count.
CleanExpiredEntries now subtracts the size of the entry it
actually deleted. A concurrency test recomputes expected counters
from the final map contents.
2026-08-14 00:01:17 +01:00
lukaszraczylo ccc25426dd fix(websocket): join ping goroutine and unify keepalive deadlines
The handler returned while the ping goroutine could still write to
a connection that gofiber had recycled into its pool. This caused
a nil dereference panic or a ping on an unrelated socket.

Join the ping goroutine through a done channel before the handler
returns. Compute effective keepalive values once in the
constructor, clamp pongTimeout to at least pingInterval with one
warning, and pass the effective values to both proxy directions.
A negative interval or timeout disables keepalive. New test drives
the real HandleWebSocket wiring.
2026-08-14 00:01:03 +01:00
lukaszraczylo 5dec954632 fix(api): hold bannedUsersMu across reload and release flock on all paths
The read-error path in loadBannedUsers returned while it held the
shared file lock. One transient read failure blocked every later
ban write for the process lifetime. The reload also applied a stale
snapshot over a ban that landed between the read and the replace.

Acquire bannedUsersMu first, then the file lock, for the whole
read and replace. Release both locks with defer on every path.
A deterministic test proves the lock is released after a read
error.
2026-08-14 00:00:36 +01:00
lukaszraczylo cffd762ae5 fix(graphql): guard fragment spread resolution against cycles
The parser does not reject fragment cycles. A 60-byte query with a
self-referential fragment caused unbounded recursion and a stack
overflow, which recover cannot catch. This was a remote DoS when
BLOCK_SCHEMA_INTROSPECTION was enabled.

Thread a visited set through checkSelections so each fragment is
entered at most once per operation. Tests cover self cycles, mutual
cycles, and a deep acyclic chain that must still detect
introspection.
2026-08-14 00:00:22 +01:00
lukaszraczylo a31418b1a3 fix(server): publish proxy app on listen and bound the shutdown drain
The shutdown component could observe the app before Listen ran.
fasthttp then treated Shutdown as a no-op and reset its stop flag.
The proxy started to serve after shutdown was requested.

Publish httpProxyApp from the OnListen hook and clear it after
Listen returns. Honor the shutdown context and retry until the
drain completes or the context expires. Gate the tests on the
publish signal with a bounded context.
2026-08-13 23:59:59 +01:00
lukaszraczylo 40eba2845c deps: bump grpc v1.81.1->v1.82.1 and x/text v0.38.0->v0.39.0 (govulncheck fixes GO-2026-6061, GO-2026-5970) 2026-08-13 15:22:21 +01:00
lukaszraczylo df2f80f55f fix(cache): make in-memory backend Close-able so Shutdown stops its cleaner
cache.Shutdown only called Close() on an io.Closer, which only the Redis
backend (CacheWrapper) satisfied -- the in-memory Cache's Shutdown
(cancels ctx, stopping its cleanupRoutine background ticker) was never
reached, so that goroutine leaked until process exit (and with the
default set-up, and whenever Redis is unreachable and EnableCache falls
back to memory).

Add Close() error to Cache (calls Shutdown, idempotent, leaves the
cache usable) so cache.Shutdown handles both backends symmetrically.
Update Shutdown doc; add test TestMemoryCacheClose_IdempotentAndStillUsable.
2026-08-13 11:36:52 +01:00
lukaszraczylo d410fb3c7e fix(websocket): wire keepalive read deadlines and ping goroutine
PingInterval/PongTimeout were configured but unused, so a half-open peer
(TCP open but silent) left both proxy goroutines and the handler blocked
in ReadMessage forever. Wire both directions: read deadline refreshed by
real traffic or pong, plus a sidecar ping goroutine (WriteControl is
safe to call concurrently with WriteMessage). Add regression test
TestWebSocketProxy_ReapsSilentBackend_Keepalive proving a silent backend
is reaped via the read deadline instead of hanging.
2026-08-13 10:20:12 +01:00
lukaszraczylo 56eb0af213 fix: recover introspection block across named fragment spreads; dedup cache hash; fix cache Set counter race
- graphql.go: parseGraphQLQuery now indexes top-level FragmentDefinition nodes
  and checkSelections resolves FragmentSpread by name, closing an
  introspection-blocking bypass (`{ ...F }` where F spreads __schema was
  allowed because the spread carried no selection set and definitions loop
  skipped fragments).
- proxy.go/server.go: handleCaching stores the computed cache hash on the
  request context; performProxyRequest reuses it instead of recomputing a
  full MD5 of the request body per proxied request (fallback preserved for
  direct callers).
- cache/memory: Set serializes the exists-check + counter update + store
  with the embedded mutex so concurrent Sets of the same new key can't
  double-increment entryCount/memoryUsage; regression test added.

Verification: go build, go vet, gofmt clean; full go test ./... and
go test -race ./... green (9 packages); golangci-lint 0 issues; trufflehog clean.
2026-08-13 09:07:52 +01:00
lukaszraczylo 14be2398e8 fix: restore tracing endpoint validation; honor per-side client timeouts; cache eviction target; redis close
- tracing: validateEndpoint used fmt.Sscanf("%s:%d") which treats %s as
  greedy, so every valid host:port endpoint failed and traced==nil disabled
  tracing entirely. Validate with net.SplitHostPort (pure helper, port range
  0-65535); test TestValidateEndpoint.
- proxy: createFasthttpClient used clientTimeout for every timeout slot,
  ignoring the parsed CLIENT_READ_TIMEOUT/CLIENT_WRITE_TIMEOUT (silent
  config loss, documented separately in README). Honour per-side read/write
  timeouts, falling back to client timeout when unset.
- cache/memory: evictToFreeMemory capped candidates at maxCacheSize/5 and
  stopped at the subset end, so a large write could leave the cache over
  its memory limit. Collect all entries and sort.Slice (correct + O(n log n)
  vs O(cap^2)), evict until the target is met or the cache is drained.
- cache/redis: the Redis cache client was never closed at shutdown (unlike
  the metrics aggregator). Add RedisConfig.Close, CacheWrapper.Close, a
  cache.Shutdown and register it with the ShutdownManager.
2026-08-13 07:16:52 +01:00
lukaszraczylo fc28c65681 fix(proxy): keep Burst/Endpoints in ratelimit unmarshal; redact large truncated log bodies
- ratelimit: custom UnmarshalJSON temp struct only carried interval/req,
  silently dropping the configured 'burst' and 'endpoints' fields. Burst is
  consumed at checkRateLimit (burst allowance) but could never be set via
  config file. Carry all fields in the temp struct.
- sanitization: a large non-parsable body was truncated before redaction,
  potentially leaking a secret in the first MaxLogBodySize bytes, and the
  truncation could split a multi-byte UTF-8 rune. Redact the retained
  prefix and truncate on a rune boundary (sanitizeTruncatedBody).
- tests: TestRateLimitConfigUnmarshalKeepsBurstAndEndpoints,
  TestSanitizeTruncatedBodyRedactsAndKeepsUTF8, TestTruncateUTF8RuneBoundary.
2026-08-13 05:55:36 +01:00
lukaszraczylo dd2aa922c5 fix(config): support float64 env values in getDetailsFromEnv
The generic typed env reader only handled string/int/bool; any float64 key
fell through to default, so CIRCUIT_FAILURE_RATIO, CIRCUIT_BACKOFF_
MULTIPLIER and RETRY_BUDGET_TOKENS_PER_SEC were silently impossible to
override via environment (always the compile-time default).

Add a float64 case mirroring the int one: parse the GMP_ prefixed key,
then the plain key, else the default (envutil has no GetFloat).

Test: extended Test_getDetailsFromEnv with float-valid and float-malformed
cases.
2026-08-13 05:20:41 +01:00
lukaszraczylo 9fcd324dc8 fix(api): make banned-users store atomic against periodic reload
bannedUsersIDs (sync.Map) was modified by admin Store/Delete (apiBanUser/
apiUnbanUser) and by loadBannedUsers->replaceBannedUsers, which does a
clear-then-rebuild (Range-Delete then Store). The flock only serialized
FILE I/O, not the in-memory map, so a reload could clear a just-applied
ban before storeBannedUsers snapshotted it (or persist the wiped set to
disk).

Add bannedUsersMu (mutex) held across both the map update and the file
write in the admin path, and across the clear-then-rebuild in
replaceBannedUsers. Lock ordering: bannedUsersMu before the file lock;
loadBannedUsers now releases its file read lock before applying the
snapshot, so no ordering inversion and no deadlock.

Also harden the connection-pool health type assertion against a missing
map key (apiConnectionPoolHealth).

Test: TestBannedUsersConcurrentModifyAndReload (concurrent modify +
reload must not lose a persisted ban; race-free under -race).
2026-08-13 04:45:28 +01:00
lukaszraczylo 2c9b0a499f fix(memory, connection): two data-race/counter defects
1) connection_pool.go: GetConnectionStats read lastRecoveryAttempt (a
multi-word time.Time) without any lock while checkAndRecover wrote it
under recoveryMutex -> data race on live admin API endpoints. Snapshot it
under recoveryMutex (the writer's lock). Regression test
TestGetConnectionStatsRace drives the read path concurrent with the locked
write under -race.

2) cache/memory memory.go: Cache.Get expired path used an unconditional
Delete + atomic decrement, so concurrent Gets on the same expired entry
each decremented entryCount/memoryUsage -> permanent negative drift. Use
LoadAndDelete-with-guard (as evictOldest/evictToFreeMemory already do) so
only the goroutine that actually removes the entry decrements. Regression
test TestMemoryCacheConcurrentGetOnExpiredEntry_CountersConsistent.
2026-08-13 04:02:29 +01:00
lukaszraczylo 9bb9db441f fix(health): release health checks when startup probe times out
StartHealthChecking's goroutine captures startupProbe=true and blocks on
readinessChan until the readiness probe completes. WaitForBackendReady only
closed readinessChan on SUCCESS; on timeout it returned an error without
closing it.

main proceeds anyway after a timeout ('Staring proxy anyway'), so the
health-check goroutine stayed blocked forever on readinessChan: periodic
checks never began and bhm.IsHealthy() stayed false permanently, so the
backend could never be marked healthy again even after it recovered.

Release readinessChan on the timeout path too (via sync.Once for safety),
so the health-check goroutine proceeds to periodic checking.

Test: TestHealthChecksStartAfterStartupTimeout (after WaitForBackendReady
times out, readinessChan is closed).
2026-08-13 03:32:29 +01:00
lukaszraczylo 546ded1d9c fix(pools): return pooled gzip reader on Reset error
GetGzipReader got a reader from the pool, Reset it, and on error returned a
fresh gzip.NewReader while never returning the pooled reader to the pool —
one leaked reader per reset error (non-gzip input), silently draining the
pool. Return the pooled reader without Close (Close panics because the
decompressor is nil until a header parses); the next caller's Reset
reinitialises it.

Test: TestGetGzipReader_ResetErrorPath (error path leaves the pool usable
for subsequent valid gzip reads).
2026-08-13 03:10:21 +01:00
lukaszraczylo bbd43bfe9e fix(coalesce): release waiters when primary fn panics
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).
2026-08-13 03:10:18 +01:00
lukaszraczylo b828915530 fix(errors): recover retryable/status from wrapped ProxyError
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.
2026-08-13 03:10:15 +01:00
lukaszraczylo 0e8a8fe33f fix(websocket): tear down peer connection and bound backend messages
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).
2026-08-13 03:10:04 +01:00
lukaszraczylo db2216973e fix(server): gracefully shut down HTTP proxy to drain in-flight requests
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.
2026-08-13 02:37:14 +01:00
lukaszraczylo d04ab329a1 fix(cache/redis): scope Clear to owned keys; drop pointless Info call
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.
2026-08-13 02:03:50 +01:00
lukaszraczylo 963f72ff17 fix(retry): guard RetryBudget mutable config against data race
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.
2026-08-13 01:20:55 +01:00
lukaszraczylo 6fd8f99b7e fix(proxy): stop retrying non-retryable 4xx responses
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.
2026-08-13 00:58:27 +01:00
lukaszraczylo 5378e6c33a test(proxy): make Test_proxyTheRequest deterministic with local httptest servers
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.
2026-08-13 00:42:23 +01:00