Server: move the auth rate limiter to a shared sliding-window store (closes #182) - #185
Draft
testtest126 wants to merge 4 commits into
Draft
Server: move the auth rate limiter to a shared sliding-window store (closes #182)#185testtest126 wants to merge 4 commits into
testtest126 wants to merge 4 commits into
Conversation
…loses #79) The /auth/* throttle added for #32 counted requests in process memory, so a multi-instance deployment would multiply the effective limit by the instance count and every restart reset all windows. Counters now live in a new auth_rate_windows table in the application database — the store all instances already share — via an atomic upsert (INSERT ... ON CONFLICT ... DO UPDATE ... RETURNING), which works on both Postgres and SQLite. The window also slides now: a verdict weighs the current bucket plus the previous one decayed linearly, closing the 2x burst that fixed windows admit across a window boundary. Refused requests count too, so hammering past the limit keeps the client limited. Stale buckets are swept at most once per window to keep the table at ~two rows per active key. Same knobs as before (AUTH_RATE_LIMIT, AUTH_RATE_LIMIT_WINDOW); tests now cover cross-instance sharing, boundary smoothing, and the sweep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E9dkcpbwWWHrdGzpvWZd1T
SwiftFormat's indent rule wants multiline string contents at the statement's own indentation (the existing Migrations.swift style), not one level deeper. String contents are unchanged — Swift strips indentation relative to the closing delimiter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E9dkcpbwWWHrdGzpvWZd1T
From an adversarial review of the PR diff: - The stale-bucket sweep now runs after the verdict is decided and logs failures instead of throwing — housekeeping could previously turn an already-decided request into a 500. - The sweep reclaims any bucket outside current±1, so changing AUTH_RATE_LIMIT_WINDOW (which renumbers buckets) can't strand rows, and a clock-ahead peer's current bucket survives. - Keys are clamped to 64 chars: real keys are IPs, and a hostile proxy-supplied header must not blow index row-size limits and turn the upsert into an error path. - Refusal counting saturates at limit+1, restoring the fixed window's recovery-after-rollover for hammered keys and keeping Retry-After honest; tests pin the exact retry value and the saturation cap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E9dkcpbwWWHrdGzpvWZd1T
The prior BLOCK @ 1b466b4 (independent second review of the closed PR #136) found the earlier APPROVE unsound and flagged three concrete gaps before this branch could be revived for #182: - Missing DB-error fail-closed coverage: add a limiter-level test (the check throws when the store is unqueryable) and a middleware/HTTP-level test (a broken store yields a non-2xx response and creates no account). - Missing genuine concurrency coverage: sequential awaits can't catch a check-then-act race. Add a test that fires real concurrent requests at one shared connection via withThrowingTaskGroup and asserts the atomic upsert holds the budget to exactly the configured limit. - CI only ever validated the raw SQL against SQLite. Add a non-required CI job (server-postgres) that runs the rate-limiter test group against a real local Postgres, so the actual ON CONFLICT … DO UPDATE … RETURNING syntax is exercised before deploy, not just SQLite's dialect-compatible mode. Scoped to the rate-limiter tests rather than the whole suite: unrelated tests assert global table counts that only hold under SQLite's per-test fresh in-memory database, not a shared persistent Postgres. Verified locally: all 96 chess-server tests pass against SQLite; the 12 rate-limiter/HTTP-surface tests pass against a real local Postgres 16.
This was referenced Jul 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #182 (the chosen Postgres-backed fix). Refs #79 (parent, "premature until the server runs more than one instance" — re-parked 2026-07-12, now being revived).
This is a revival, not a new build. History for the next reviewer, so nobody starts from a stale claim:
issue-79-pg-rate-limiter) was previously opened as PR Server: move the auth rate limiter to a shared sliding-window store (closes #79) #136, got aSecurity review: APPROVE @ 1b466b4, was un-drafted, then closed unmerged per the owner's "Rate limiter: shared store once the server scales past one instance #79 stays parked" decision — the closing note pointed future revivals at "a security APPROVE at that exact head."Security review: BLOCK @ 1b466b4, explicitly voiding that APPROVE as unsound (it claimed a fail-closed-on-DB-error test existed when none did, and the PR was un-drafted 4 seconds after the APPROVE with zero line comments). Code itself was assessed "close to approvable" — three concrete gaps:ON CONFLICTupsert path against real Postgres never ran.This PR rebases the original implementation onto current
mainand closes all three gaps for real:testLimiterFailsClosedWhenStoreIsUnavailablebreaks the store and assertscheckthrows;testMiddlewareFailsClosedWhenStoreIsUnavailableasserts the HTTP layer turns that into a non-2xx response with no account created.testConcurrentRequestsShareOneAtomicBudgetfires 40 real concurrent requests at one shared connection viawithThrowingTaskGroupand asserts the atomic upsert holds the budget to exactly the configured limit — the race a non-atomic "SELECT then write" store would fail.server-postgresin.github/workflows/ci.ymlinstalls local Postgres 16 and runs the rate-limiter test group against it (not the whole suite — unrelated tests assert global table counts that only hold under SQLite's per-test fresh in-memory database, not a shared persistent Postgres). Validated locally: the exactINSERT … ON CONFLICT … DO UPDATE … RETURNINGruns correctly against real Postgres 16, and the 12 rate-limiter/HTTP-surface tests pass there.What it does (accurate this time)
auth_rate_windows(keytext,bucketbigint,countint, unique onkey+bucket), added viaCreateAuthRateWindowmigration.window-second bucket; the verdict weighs the current bucket plus the previous bucket decayed linearly by how far the current bucket has progressed — closes the 2× boundary burst a fixed window admits.INSERT … ON CONFLICT … DO UPDATE … RETURNINGcounts and reads in a single statement, so concurrent requests across instances can never both take the last free slot.Retry-After; a DB failure propagates as an uncaught error, which Vapor's default error middleware maps to a generic 500 — the request never reaches the route handler either way (fail-closed).AUTH_RATE_LIMIT,AUTH_RATE_LIMIT_WINDOW, same middleware/keying (Fly-Client-IP → last XFF → peer address), same 429 surface as the old in-memory limiter.Not touched: PR #116 / branch
claude/smiley-face-1xzfkz(a separate, independent implementation of the same feature) and its stack of open descendants (#161, #164, #177) — explicitly out of scope for this PR.Test plan
swift test --package-path chess-server: 96/96 pass at head511a5e2(Xcode-beta toolchain, local run).swift test --package-path ChessKit: untouched by this PR (no ChessKit files changed).server-postgresadded to validate this on every push going forward (informational, not required).Security-sensitive: draft, awaiting review
This touches abuse protection on the auth surface (CLAUDE.md rule 5). Stays draft until a root/orchestrator session posts a fresh verdict — the prior APPROVE at
1b466b4is void (superseded by the BLOCK, and doubly void by this rebase to a new head). This PR does not self-merge, arm auto-merge, or carry any security verdict.Approval command (run by the human, at the exact pushed head):