Skip to content

Server: move the auth rate limiter to a shared sliding-window store (closes #182) - #185

Draft
testtest126 wants to merge 4 commits into
mainfrom
issue-79-pg-rate-limiter
Draft

Server: move the auth rate limiter to a shared sliding-window store (closes #182)#185
testtest126 wants to merge 4 commits into
mainfrom
issue-79-pg-rate-limiter

Conversation

@testtest126

Copy link
Copy Markdown
Owner

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:

  • This branch (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 a Security 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."
  • An independent second review then posted 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:
    1. PR body described schema/sweep/429-vs-500 details the diff didn't actually contain.
    2. Missing tests: DB-error fail-closed, and a genuinely concurrent burst (not sequential awaits) that would catch a non-atomic store.
    3. CI only ever exercised SQLite; the production ON CONFLICT upsert path against real Postgres never ran.

This PR rebases the original implementation onto current main and closes all three gaps for real:

  1. Fail-closed, end to end. testLimiterFailsClosedWhenStoreIsUnavailable breaks the store and asserts check throws; testMiddlewareFailsClosedWhenStoreIsUnavailable asserts the HTTP layer turns that into a non-2xx response with no account created.
  2. Genuine concurrency. testConcurrentRequestsShareOneAtomicBudget fires 40 real concurrent requests at one shared connection via withThrowingTaskGroup and asserts the atomic upsert holds the budget to exactly the configured limit — the race a non-atomic "SELECT then write" store would fail.
  3. Real Postgres in CI. New non-required job server-postgres in .github/workflows/ci.yml installs 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 exact INSERT … ON CONFLICT … DO UPDATE … RETURNING runs correctly against real Postgres 16, and the 12 rate-limiter/HTTP-surface tests pass there.

What it does (accurate this time)

  • Schema: new table auth_rate_windows (key text, bucket bigint, count int, unique on key+bucket), added via CreateAuthRateWindow migration.
  • Sliding window: each request lands in a 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.
  • Atomicity: one INSERT … ON CONFLICT … DO UPDATE … RETURNING counts and reads in a single statement, so concurrent requests across instances can never both take the last free slot.
  • Sweep: buckets outside current±1 are deleted at most once per window (after the verdict, errors logged not thrown) so the table stays bounded.
  • 429 vs 500: a limited request gets 429 + 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).
  • Same config surface: 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 head 511a5e2 (Xcode-beta toolchain, local run).
  • swift test --package-path ChessKit: untouched by this PR (no ChessKit files changed).
  • Rate-limiter test group (12 tests) run against a real local Postgres 16: pass.
  • New CI job server-postgres added 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 1b466b4 is 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):

Security review: APPROVE @ 511a5e261da451dd37352b6a46a90e3fe64ca8fd

claude and others added 4 commits July 14, 2026 21:48
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement sliding window rate limiter with Postgres

2 participants