feat(api): rate limiter — AND-checked user/space/org buckets, fail-open, Retry-After (INFRA-027) - #108
Merged
Conversation
… 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
marked this pull request as draft
September 17, 2026 20:22
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.
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 isscope + 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 →429throughmakeErrorwithRetry-AfterandCache-Control: no-store, or the route'sonLimited. Any Redis error → fail open: warn +errorReportercapture, request allowed.incrementFixedWindows— one atomic LuaINCR+ first-hitPEXPIREper 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-mostx-forwarded-forhop (never the client-supplied[0]), IPv6 bucketed to /64 for limiting;clientAddresskeeps the full address and now feedsauditActorMiddleware, closing the forgeable audit IP as well.middleware/rateLimit/identities.ts, fromgetActor):userIdentity— a session and every token owned by or through the user shareuser:{id}principalIdentity— user, elseip:{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.rateLimitPerSecondis no longer read and retires with that work.app.fetchskip the limiter. The skip keys on the batch transaction prepareRequest resolves from the registry — a spoofedx-batch-idheader still counts, and a real batch throughPOST /api/v1/batch/executecarrying more sub-requests than the per-second max comes back all-200 (both tested).apiRateLimit(principal AND space AND organization) after auth on every/apiroute;authRateLimitper IP on/api/auth/*.emailRateLimithad no consumer and is removed.makeErrorgainsheadersso the envelope stays the single owner of the 429 body.queries/. The window script sits inmiddleware/rateLimit/queries/, and the three lanes scripts (evictingClaim,ageGatedReclaim,fencedRelease) move intopackages/db/src/lanes/queries/, matching INFRA-031: createLock refresh is one compare-and-expire eval; tri-state release; singleton on the queue connection #104'slock/queries/convention. A newlua-in-queriesCI rule (with pass/fail fixtures) holds the line:redis.callonly under aqueries/folder.apiRateLimit.tsandauthRateLimit.tsare one export each, likemiddleware/auth/andmiddleware/validations/.Redis cost
One
EVALper 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 anINCR+ first-hitPEXPIRE+PTTLon 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 returnsundefinedfor 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 withRetry-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
scripts/ci/rules/*.shpassrun-ci-rules.sh --testself-tests passFollow-ups (on the ticket)
rateLimitMaxfrom subscriptions / feature flags (FEAT-003), dropToken.rateLimitPerSecond🤖 Generated with Claude Code