Rate limiting
Throttling lives in api/src/middleware/rate-limit.ts. Two limiters share one fixed-window implementation but are keyed differently and applied to different surfaces.
Per-client limiter — rateLimit
rateLimit({ limit, windowMs, key }) is a factory returning a middleware. It maintains a Map<string, { count, resetAt }> of fixed windows. On each request it derives the bucket key, increments the count, and — if the count exceeds limit within the current window — returns 429 with a Retry-After header (seconds until the window resets):
if (b.count > opts.limit) {
c.header('retry-after', String(Math.ceil((b.resetAt - now) / 1000)));
return c.json({ error: { code: 'rate_limited', message: 'Too many requests' } }, 429);
}
The clientKey(c) helper keys by edge IP (cf-connecting-ip, then x-forwarded-for, then the literal anonymous). This limiter is applied to the sensitive unauthenticated surfaces in api/src/app.ts:
| Surface | Limit | Window | Key |
|---|---|---|---|
/public/surveys/* (respondent capture) | 30 | 60s | client IP |
/r/:slug (short-URL redirect) | 60 | 60s | client IP |
/public/signups (feature-flag read) | 30 | 60s | client IP |
Per-API-key limiter — apiKeyRateLimit
apiKeyRateLimit({ limit, windowMs }) runs after requireTenant on all of /v1/*:
app.use('/v1/*', apiKeyRateLimit({ limit: 600, windowMs: 60_000 }));
It meters only requests made with an API key. The principal's email for a key is apikey:<public_id> (set in verifyApiKey), so the middleware keys the bucket by that id and skips anything else:
const email = c.get('tenant')?.email ?? '';
if (!email.startsWith('apikey:')) return next(); // session traffic is never metered here
The effect: a runaway integration cannot exhaust the API, while interactive (session) traffic from the SPA passes through untouched. Exceeding the limit returns 429 { error: { code: 'rate_limited', message: 'API key rate limit exceeded' } } with a Retry-After header. The default is 600 requests/minute per key.
In-isolate limitation
Both limiters are in-isolate (per-Worker): the counter Map lives in the memory of a single Worker isolate. Cloudflare runs many isolates across points of presence, and each keeps its own counters, so the effective global limit is higher than the configured per-isolate number and is not consistent across the fleet. This is a deliberate first line of defence that bounds abuse cheaply without a network round-trip.
A globally-consistent limit is a planned refinement: a Durable Object or KV-backed counter slots in behind the same middleware shape (rateLimit / apiKeyRateLimit), so call sites in app.ts do not change when the backing store is upgraded. Both functions document this in their source comments.
Notes for integrators
- Treat
429as retryable: honour theRetry-Afterheader before retrying. - The health/readiness probes (
/health,/health/ready) are not rate-limited. - See Authentication for how a key becomes the
apikey:<id>principal, and API keys for key lifecycle and scopes.