rate limits LIVE
Two throttle layers sit in front of the shinnslist api: an edge-wide per-IP throttle and per-endpoint token buckets keyed to your caller identity. Everything below describes code that is actually deployed — anything still planned is marked DRAFT.
functions/_middleware.js, functions/lib/rate-limit.js, functions/lib/api-keys.js, functions/lib/v1.js). There is no X-RateLimit-Limit / X-RateLimit-Remaining header convention anywhere in the codebase — retry guidance lives in the Retry-After header and the retry_after_seconds body field. No header we claim to document exists that the code does not send.layer 1 — edge throttle: 100 req/min per IP
Every request passes through middleware before anything else. The rule: 100 requests per minute per client IP, sliding 60-second window.
- IP resolution (reverse-proxy aware):
CF-Connecting-IPfirst, then the first hop ofX-Forwarded-For, thenX-Real-IP. Behind Cloudflare this is your real client IP, not the CDN's. - exemptions: only
GET /api/healthandGET /api/statusskip the throttle (public liveness probes). Everything else is counted. - the window is a true sliding window, not a per-minute bucket: a denied request is refused when its timestamp falls within 60s of the 100th counted request.
retry_after_secondsis the seconds until the oldest counted request ages out of the window — that's the earliest you can retry and be counted fresh.- denied requests do not consume quota. A 429 does not add a timestamp to the window, so the window recovers exactly 60 seconds after the first counted hit — there is no penalty extension for bursting.
- in-memory per worker isolate: the counter lives in the isolate's memory, so under a multi-isolate deployment the effective limit is ~100/min per isolate for one IP. See the planned section below.
- every denial is logged with error class
rate_limited, so abuse spikes show up in the/api/healtherror readout.
Exact 429 from layer 1 (carries a Retry-After header):
{
"error": "rate_limited",
"message": "Too many requests — 100 per minute per IP.",
"retry_after_seconds": 34,
"ip": "203.0.113.9"
}
layer 2 — per-endpoint token buckets (per key or IP)
Past the edge throttle, each endpoint family gets its own KV-backed token bucket, refilled on a fixed window. Buckets are scoped to your identity: a valid API key's scope for keyed calls, the client IP for anonymous or operator endpoints. Rejected requests do not write state — no stampede, no counter inflation.
| endpoint family | scope | limit / window |
|---|---|---|
/api/intel parcel queries | intel:<key or ip> | tiered: anon 2/min · free 10/min · pro 60/min |
POST /v1/signals/{signal}/evaluate | v1eval:<key> | tiered: same perMin as intel |
| mcp tool calls | mcp:<key or ip> | tiered: same perMin as intel |
POST /api/keys (key issuance) | issue:<ip> | 3 per hour |
/api/audits uploads | audit-upload (global) | 10 per hour |
/api/goals | goals:<ip> | 30 per minute |
dev-mode note: when the KV store is unbound, the bucket check is skipped entirely (limits off) — the edge throttle in layer 1 is always on regardless.
exact 429 shapes
The layers stack — layer 1 fires first. Each layer's 429 is a different shape; the body below is copied, not paraphrased.
- layer 1 — edge throttle (carries
Retry-Afterheader){"error": "rate_limited", "message": "Too many requests — 100 per minute per IP.", "retry_after_seconds": 34, "ip": "203.0.113.9"} - layer 2 — REST endpoints (
/api/intel, mcp, audits, goals; carriesretry-afterheader){"ok": false, "error": "rate_limit_exceeded", "retry_after_seconds": 34} - layer 2 — key issuance (
POST /api/keys; carriesretry-afterheader){"ok": false, "error": "issuance_rate_limit_exceeded", "retry_after_seconds": 3600} - layer 2 — v1 signal apis (
POST /v1/signals/{signal}/evaluate; noRetry-Afterheader — read the body field){"request_id": "req_9f2c…", "ok": false, "error": "rate_limited", "message": "Rate limit exceeded; retry in 34s.", "retry_after_seconds": 34}
{"ok": false, "error": "daily_quota_exceeded", …} is a quota exhaustion (day's calls used up), not a burst limit — its Retry-After counts down to midnight UTC. Full shape on the api keys page.client guidance
- always honor the
Retry-Afterheader (orretry_after_secondsin the body) — it is exact for layer 1 and a best-effort refill estimate for layer 2 token buckets. - back off exponentially with jitter if you keep hitting 429s; a fixed-interval retry loop will just ride the limit.
- remember the layers compose: a single IP making
/api/intelcalls faces the tiered bucket (10/min onfree) inside the 100/min edge throttle. Multiple keys from one IP share the outer 100/min. - an API key is scoped per key, not per machine: two processes sharing one key share one bucket.
what happens on abuse
Honestly, not much beyond the throttle itself — and that's deliberate:
- no IP lockout, ban list, or 403-on-abuse exists in the code. Sustained bursting gets you sustained 429s until the window recovers; there is no escalating penalty.
- denials don't consume quota and don't extend the window — the code refuses to write any state for a rejected request, so an attacker can't make the throttle stricter by poking it.
- every denial is logged (error class
rate_limitedwith the endpoint and retry time), so an abuse spike is visible to operators in the/api/healtherror readout. - known limitation: the daily quota counter is a KV read-modify-write with no transaction, so tight bursts from concurrent requests can overshoot the cap slightly. Burst metering, not hard metering — acceptable for freemium gating.
planned: cross-isolate limiter DRAFT
The layer-1 throttle is in-memory per worker isolate — the deployed code carries an explicit note that a cross-isolate limiter (Durable Object or KV-backed) is the future upgrade if bursty single-IP traffic ever becomes real. No endpoint, no config flag, and no shared-bucket behavior ships today; the real docs land on this page the day the limiter ships. Bucket names, window math, and migration behavior here are not final.
next: error reference · api keys · getting started · api reference · docs hub