Commit Graph
3 Commits
Author SHA1 Message Date
lukaszraczylo 5700dd482d fix(limits): never fabricate zero counters, bound the rejection counter
Two correctness fixes from adversarial verification.

providerUsage returned every counter at its zero value on a
failed-closed store read, with no signal distinguishing that from a
real zero. metrics.go rendered the zero as a hard value. A single
Redis blip therefore read as a counter reset, and the recovery scrape
counted the whole prior total as a fresh increase, painting a phantom
multi-thousand-request spike from one transient error. Add storeDown
to providerCounters and set it on the failed-closed path, mirroring
scopeUsage's existing contract.

limiter.rejections grew without bound: nothing ever removed a scope
whose user was renamed or deleted from a hot-reloaded users file, so a
long-running process kept exporting a series for every name ever seen.
Bound the map with the same current/previous generation-rotation
authFailureTracker already uses, and evict a user's entry when
authStore.maybeReload performs a real reload. maybeReload now returns
whether it actually reloaded, so ServeHTTP only runs the eviction walk
on that rare event, never on every request.

Also corrects a stale comment: the rejection counter's store-down
branch fires on every request during a full outage, not rarely, as an
earlier comment claimed.
2026-08-23 00:58:02 +01:00
lukaszraczylo 15bbf51deb fix(auth): non-blocking reload check, stat-before-load, mtime inequality trigger
- users_file.go: maybeReload uses reloadMu.TryLock() instead of Lock().
  A full reload (stat+read+parse+replaceFileUsers, incl. per-user
  resolveSecret file reads) could otherwise block every concurrent
  ServeHTTP request for its whole duration, negating the buildMu/mu
  lock split. A request that finds a reload in flight now skips this
  cycle; reload stays throttled and best-effort.
- users_file.go: mtime check changed from ModTime().After(lastModTime)
  to ModTime().Equal(lastModTime), so a backward mtime move (restore
  from backup, cp -p) still triggers a reload instead of being
  skipped. Added a regression test with os.Chtimes into the past.
- llmgateway.go: attachUsersFile now stats the file before loading it,
  matching maybeReload's ordering, so a write landing between stat and
  load is caught on the next check instead of being lost. Documented
  why the ignored statErr is safe (falls back to zero lastModTime,
  causing one harmless extra reload).
2026-08-20 01:16:13 +01:00
lukaszraczylo eef33b8232 feat: users file with throttled hot reload
Add usersFile (newUsersFile/load) for the JSON users-file shape
{"users":[{name,group,apiKey,limits?}]}, and authStore.maybeReload
for throttled hot reload: checks mtime at most every reloadEvery
(5s), reloads on change, keeps the last good set and logs via
errorf on any error (stat, parse, or a rejected replace), and logs
via logf when the reload changes the file-sourced user count.
nowFn is injectable for tests.

Wire-up in llmgateway.go: newGateway does a synchronous initial
load when Users.File is set (a bad initial file fails the
constructor); ServeHTTP calls g.auth.maybeReload() at entry
(no-op when no file is configured).

Also fixes replaceFileUsers's merge semantics: a file user now
overrides an inline user of the same name, even when their keys
differ, dropping the inline entry from the rebuilt set. The prior
digest-only keying let a same-named inline/file pair with
different keys coexist instead of the file version winning.
2026-08-20 01:07:31 +01:00