Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 8 additions & 9 deletions apps/api/src/plugins/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,17 @@
* In-memory rate-limit plugin.
*
* Enforces per-IP and per-account caps per specs/api/conventions.md#rate-limiting:
* - Unauthenticated reads: 1200 req / min / IP
* - Unauthenticated reads: 300 req / min / IP
* - Authenticated reads: 300 req / min / account
* - Writes: 30 req / min / account (120 / min / IP anonymous)
* - Credential endpoints: 120 req / min / IP
* - Writes: 30 req / min / account (30 / min / IP anonymous)
* - Credential endpoints: 20 req / min / IP
*
* Only `/api/**` is counted. The SPA shell, its assets, and thumbnails come out
* of the same process and a single page load fetches dozens of them; counting
* those against the read cap is what produced a site-wide 429 storm at cutover.
*
* Per-IP caps are generous on purpose: production's load balancer does not yet
* preserve client addresses (cfp-live-cluster #201), so every visitor shares
* one "IP" until that lands.
* Per-IP caps assume the first X-Forwarded-For hop is the real client — the
* NodeBalancer speaks PROXY protocol to Envoy (cfp-live-cluster #203).
*
* Counters are reset on restart (intentional — single replica, civic scale).
* Exceeded limit → RateLimitedError(retryAfterSeconds).
Expand All @@ -32,11 +31,11 @@ interface BucketEntry {
const WINDOW_MS = 60_000; // 1 minute

export const RATE_LIMITS = {
unauthenticatedReadsPerIp: 1200,
unauthenticatedReadsPerIp: 300,
authenticatedReadsPerAccount: 300,
writesPerAccount: 30,
anonymousWritesPerIp: 120,
credentialPerIp: 120,
anonymousWritesPerIp: 30,
credentialPerIp: 20,
} as const;

function getOrCreate(map: Map<string, BucketEntry>, key: string): BucketEntry {
Expand Down
4 changes: 3 additions & 1 deletion plans/rate-limit-scope.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,6 @@ cap back down once real client IPs arrive.

## Follow-ups

- Tracked as: cfp-live-cluster #201 (PROXY protocol so per-IP is per visitor).
- Tracked as: cfp-live-cluster #203 landed PROXY protocol the same night; the
generous caps were then tightened to real per-visitor numbers (reads 300,
credential 20, anonymous writes 30 per minute per IP).
8 changes: 4 additions & 4 deletions specs/api/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,12 +161,12 @@ Single-replica means rate-limit state is in-memory. Counters reset on restart; a

Only `/api/**` requests are counted. The SPA shell, its assets, and image thumbnails are served by the same process but are never rate limited — a single page load fetches dozens of them.

- Unauthenticated reads: 1200 requests / minute / IP
- Unauthenticated reads: 300 requests / minute / IP
- Authenticated reads: 300 requests / minute / account
- Writes: 30 requests / minute / account (120 / minute / IP when anonymous)
- Credential endpoints: 120 requests / minute / IP. These are the routes that accept or mint a credential: `POST /api/auth/login`, `/api/auth/github/start` and `/callback`, `/api/auth/link-github`, `/api/auth/password-reset/*`, and `POST /api/account-claim/by-password`. Session reads such as `GET /api/auth/me` (called on every page load) and `/api/auth/refresh` are ordinary reads.
- Writes: 30 requests / minute / account (30 / minute / IP when anonymous)
- Credential endpoints: 20 requests / minute / IP. These are the routes that accept or mint a credential: `POST /api/auth/login`, `/api/auth/github/start` and `/callback`, `/api/auth/link-github`, `/api/auth/password-reset/*`, and `POST /api/account-claim/by-password`. Session reads such as `GET /api/auth/me` (called on every page load) and `/api/auth/refresh` are ordinary reads.

The per-IP caps are deliberately generous: in production the gateway sits behind a load balancer that does not yet preserve client addresses, so every visitor currently shares one "IP" (cfp-live-cluster #201). Tighten the credential cap once that lands.
Per-IP caps assume the gateway delivers the real client address in the first `X-Forwarded-For` hop (the NodeBalancer speaks PROXY protocol to Envoy — cfp-live-cluster #203).

Exceeded → `429 rate_limited`, `Retry-After` header in seconds.

Expand Down
Loading