Skip to content

feat(api): rate limiter — AND-checked user/space/org buckets, fail-open, Retry-After (INFRA-027) - #108

Merged
agreenspan merged 7 commits into
mainfrom
INFRA-027-rate-limiter
Sep 17, 2026
Merged

agreenspan merged 7 commits into
mainfrom
INFRA-027-rate-limiter

Conversation

@agreenspan

@agreenspan agreenspan commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Ports Zealot's current rate limiter and wires it. Split out of #81 per review on 2026-09-17; the ticket is tickets/INFRA-027-rate-limiter-atomicity-fail-open-scopes.md (rulings section records today's decisions).

What it does

  • rateLimit(rules, { onLimited }) — Zealot's rule-array middleware. Every rule is scope + windowMs + max (number | per-request fn) + key fn; rules AND together so the strictest bucket wins and minting tokens never multiplies throughput. A null key skips the rule. Over limit → 429 through makeError with Retry-After and Cache-Control: no-store, or the route's onLimited. Any Redis error → fail open: warn + errorReporter capture, request allowed.
  • incrementFixedWindows — one atomic Lua INCR + first-hit PEXPIRE per window, returns count + remaining ttl. Replaces the incr-then-expire pair whose crash-in-between stranded a TTL-less key forever.
  • clientIp / clientAddress — trusted right-most x-forwarded-for hop (never the client-supplied [0]), IPv6 bucketed to /64 for limiting; clientAddress keeps the full address and now feeds auditActorMiddleware, closing the forgeable audit IP as well.
  • Identities (middleware/rateLimit/identities.ts, from getActor):
    • userIdentity — a session and every token owned by or through the user share user:{id}
    • principalIdentity — user, else ip:{clientIp}
    • organizationIdentity / spaceIdentity — from the token's scope (Organization / OrganizationUser / Space / SpaceUser owner models)
  • rateLimitMax(tier, c) — the one seam for limits. Code defaults today (user 20/s, space 30/s, org 60/s, auth 60/min); subscriptions / feature flags plug in here. No limit columns: Token.rateLimitPerSecond is no longer read and retires with that work.
  • These are identity limits, not cost limits. Abuse-shape buckets keyed on who is calling. A cost limit (AI calls, anything bounding spend) is a separate detector with its own semantics and must still run inside a batch — it will get its own factory or a rule flag when it arrives, never a share of these buckets. Recorded in the ticket and REDIS.md.
  • Batch sub-requests are not counted by identity limits. The batch request paid once; sub-requests dispatched through app.fetch skip the limiter. The skip keys on the batch transaction prepareRequest resolves from the registry — a spoofed x-batch-id header still counts, and a real batch through POST /api/v1/batch/execute carrying more sub-requests than the per-second max comes back all-200 (both tested).
  • Wired. apiRateLimit (principal AND space AND organization) after auth on every /api route; authRateLimit per IP on /api/auth/*. emailRateLimit had no consumer and is removed.
  • makeError gains headers so the envelope stays the single owner of the 429 body.
  • Lua lives in queries/. The window script sits in middleware/rateLimit/queries/, and the three lanes scripts (evictingClaim, ageGatedReclaim, fencedRelease) move into packages/db/src/lanes/queries/, matching INFRA-031: createLock refresh is one compare-and-expire eval; tri-state release; singleton on the queue connection #104's lock/queries/ convention. A new lua-in-queries CI rule (with pass/fail fixtures) holds the line: redis.call only under a queries/ folder.
  • No index file. apiRateLimit.ts and authRateLimit.ts are one export each, like middleware/auth/ and middleware/validations/.

Redis cost

One EVAL per active rule per request (1 for a session user or anonymous, 2–3 for a tenant-scoped token), dispatched together so a request pays one round trip; each eval is an INCR + first-hit PEXPIRE + PTTL on a key that lives for the window (1 s API, 60 s auth), so the keyspace is bounded by concurrently active identities. Collapsing a request's windows into one multi-key eval is a listed follow-up — blocked on ioredis-mock, which returns undefined for any Lua table built by a loop that runs more than once.

Tests

incrementFixedWindows (count/ttl, key isolation), clientIp (trusted hop, /64, mapped IPv4, zone id, clientAddress), rateLimit (allow→429 with Retry-After + envelope, onLimited, per-client isolation, AND rules, fn max, null-key skip, spoofed XFF can't mint a bucket, fail-open with Redis down), identities (anonymous → IP, session and user token share a bucket, org token, space token).

Validation

  • api typecheck clean, biome clean on touched files, all scripts/ci/rules/*.sh pass
  • full chain: permissions 81, db 318, email 473, api 1132 — 0 fail (rerun after the 20/s default); run-ci-rules.sh --test self-tests pass

Follow-ups (on the ticket)

  • feed rateLimitMax from subscriptions / feature flags (FEAT-003), drop Token.rateLimitPerSecond
  • resource-derived org/space context for session users, if per-tenant fair share is wanted
  • alert on the fail-open warn

🤖 Generated with Claude Code

agreenspan and others added 6 commits September 17, 2026 16:46
… organization buckets, fail-open, Retry-After (INFRA-027)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ies CI rule, one export per file instead of a rateLimit index

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…paid once

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ts it carries

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tity limits only

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@agreenspan
agreenspan marked this pull request as draft September 17, 2026 20:22
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@agreenspan
agreenspan marked this pull request as ready for review September 17, 2026 20:32
@agreenspan
agreenspan merged commit 2897cd8 into main Sep 17, 2026
@agreenspan
agreenspan deleted the INFRA-027-rate-limiter branch September 17, 2026 20:32
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.

1 participant