The Rate-Limit Contract
Every API has a rate limit — the only question is whether it is a documented 429 with headers or an undocumented collapse. The contract names the dimensions (per key, per user, per endpoint class), the numbers, and exactly how a well-behaved client should respond.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
The limit exists either way — the contract decides how clients meet it
An API without a declared rate limit still has one: the point where the database saturates and every tenant's p99 explodes. The rate-limit *contract* moves that boundary from physics to policy — one client's runaway retry loop gets 429s instead of degrading the platform for everyone. The algorithms that enforce it (token bucket, sliding window) and where the limiter sits are architecture's ground — Rate Limiting — and none of them are visible to consumers. What consumers experience is the contract: which requests count, against which bucket, what the ceiling is, and what a rejection looks like.
The rejection's anatomy is the core clause. 429 Too Many Requests — a distinct status, because clients branch on it differently from everything else: unlike a 400 it *should* be retried, unlike a 503 the fault is the caller's pace, and unlike both, the response says *when* — Retry-After: 7. Pair it with rate-limit headers (the emerging IETF standard RateLimit-* family, or the ubiquitous X-RateLimit-Limit / -Remaining / -Reset trio) on every response, not just rejections: Remaining: 3 on a 200 lets a well-written client pace itself and never see the 429 at all. That is the contract working — the error path documented so well that clients stop needing it.
POST /v1/messages HTTP/1.1
Host: api.example.com
Authorization: Bearer sk_live_51Nx…
{ "channel": "ch_9", "text": "…" }HTTP/1.1 429 Too Many Requests
Retry-After: 7
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 7
Content-Type: application/json
Request-Id: req_01J9…
{
"error": {
"code": "rate_limited",
"message": "100 requests/min per key exceeded.",
"retry_after_seconds": 7,
"docs": "https://api.example.com/docs/limits"
}
}
← the client's correct move is fully determined:
wait ≥7s (plus jitter), then resume at a lower rateDimensions: what the bucket is keyed on
A single global number is almost never the real contract. Limits are keyed on an identity, and the choice of identity decides who gets punished for whose behavior. Per-IP limits punish everyone behind a corporate NAT for one bad script and dissolve against distributed callers — they are an unauthenticated-edge defense, not a tenant contract. Per-key (or per-token) limits align the bucket with the thing you bill, scope and revoke — the natural primary dimension, which is one more reason API Keys: Identity for Applications identity matters. Per-endpoint-class limits protect asymmetric costs: 1,000 reads/min and 10 report-generations/min are both reasonable, and one shared bucket for both means cheap calls starve expensive ones or expensive ones are priced like cheap.
Real contracts stack dimensions — per-key overall, tighter per expensive endpoint class, per-IP at the unauthenticated edge (login, signup, where the limiter is doing How Passwords Are Actually Attacked-mitigation duty rather than capacity fairness). Two honesty clauses follow. State what *counts*: do 429s themselves count against the bucket (if yes, a naive retry loop can pin a client at zero forever)? Do webhook-triggered reads? And keep limiter identity aligned with billing identity — a limit keyed on user while quotas are keyed on org produces support tickets that no dashboard can explain.
| Keyed on | Protects against | Fails when | Contract role |
|---|---|---|---|
| API key / token | One integration's runaway loop or spike | One tenant runs many keys to multiply quota | Primary documented dimension — matches billing and revocation |
| User / tenant | Aggregate abuse across a tenant's keys | Multi-tenant apps funnel many users through one tenant | The fairness dimension for per-seat products |
| IP address | Unauthenticated abuse: signup, login, scraping | Corporate NAT (false positives), botnets (false negatives) | Edge defense only — never the documented tenant limit |
| Endpoint class | Expensive operations starving cheap ones | Class boundaries drawn wrong — one hot endpoint drags its class | Cost honesty: search/export/report get their own numbers |
The client's half, and the provider's honesty
A rate-limit contract is bilateral. The provider documents numbers and headers; the client is expected to honor Retry-After, back off exponentially with jitter when it is absent, and treat sustained 429s as a signal to redesign (batch the calls — Batch APIs and Partial Failure — or cache) rather than to parallelize harder. Write the expected client behavior *into the docs and the SDK*: the retry loop your SDK ships is the de facto contract for thousands of integrations, and a jitterless SDK loop synchronizes clients into waves that hammer the limiter in lockstep — Retries and Timeouts as Contract Guidance mechanics applied to your own front door.
The provider's honesty clauses are what separate a limit from a trap. Numbers in the docs, not "contact us" (nobody can size a batch job against a mystery). Headers that match enforcement (a Remaining that lies breeds clients that ignore it). Rejection *before* work — a 429 should cost you microseconds at the gate, not a database query, or the limiter fails exactly when needed (The Gateway as Policy Boundary is where that enforcement usually lives). And a distinct signal for "throttled" vs "degraded": when the *platform* is shedding load, that is a 503 story, not a silent tightening of everyone's limits — clients react differently, and deserve to know which one is happening. Limits also stratify by tier — free, paid, enterprise — which is contract too: the tier table belongs next to the pricing page, and limit *changes* are contract changes with notice, because batch jobs were sized against the old number.
1# docs: (nothing about limits)2 3HTTP/1.1 403 Forbidden4{ "error": "blocked" }5 6# no Retry-After → client guesses (usually: retry now)7# 403 → SDKs treat it as auth failure, log the user out8# limit keyed on IP → the whole office is banned together9# support learns the real numbers one ticket at a time1# docs: 100 req/min per key · 10/min for POST /exports2# 429 + Retry-After on rejection3# RateLimit-* headers on every response4 5HTTP/1.1 200 OK6RateLimit-Limit: 1007RateLimit-Remaining: 128RateLimit-Reset: 319 10# SDK behavior (shipped, documented):11# remaining low → pace proactively12# 429 → sleep max(Retry-After, backoff) + jitter13# sustained 429 → surface to caller, suggest batchingThe good contract makes correct client behavior mechanical — no guessing, no tickets, and most clients never hit the 429 because the success-path headers let them pace first. The bad one produces retry storms, logged-out users and a support queue doing the documentation's job.
Key points
- Every API has a rate limit; the contract decides whether clients meet a documented 429 or an undocumented outage.
- The rejection must be mechanical to obey: 429 (not 403, not 503), Retry-After, and standard RateLimit headers with real numbers.
- Send rate-limit headers on successes too — clients that can see
Remainingpace themselves and never hit the wall. - Key limits on what you bill and revoke (keys/tenants); per-IP belongs at the unauthenticated edge, per-endpoint-class where costs are asymmetric.
- Document the client's half — honor Retry-After, back off with jitter — and ship it in the SDK, because the SDK loop is the contract most integrations actually run.
- Enforcement algorithms are architecture (Rate Limiting); the contract is dimensions, numbers, headers and rejection semantics.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Provider → contract: ships with no declared limits; capacity is the limiter and nobody knows it.
- 2Integration → API: a partner's nightly sync fans out to 200 parallel workers because nothing said not to.
- 3Platform → tenants: the database saturates; every tenant's latency spikes — the noisy neighbor is invisible in the contract because the contract never defined "too much".
- 4Provider → firefight: ops hand-blocks the partner's IP range; their integration hard-fails with connection errors that look like an outage on their side.
- 5Both sides → aftermath: the partner's retry logic, written against no documented behavior, hammers the endpoint the moment the block lifts — and the cycle repeats.
- One client's burst degrades every tenant when limits are physics instead of policy — the platform's availability is hostage to its least careful integration.
- Undocumented or wrongly-coded rejections (403, silent drops) trigger the wrong client recovery: logout loops, blind retries, retry storms synchronized across a fleet.
- Batch jobs sized against unknown ceilings fail mid-run at unpredictable points, leaving consumers with half-processed state and no way to plan capacity.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Declare numbers per dimension and tier in the docs, return 429 + Retry-After + RateLimit headers on rejection, and the same headers on success.
- • Key the primary limit on the billing/revocation identity (key or tenant); add endpoint-class limits for asymmetric costs and per-IP only at the unauthenticated edge.
- • Enforce at the gate before real work, and keep the headers truthful to actual enforcement — a lying `Remaining` teaches clients to ignore the contract.
- • Ship the compliant retry loop in your SDKs (Retry-After, exponential backoff, jitter) and state that sustained 429s mean redesign, not more parallelism.
- • 429 rate per key and per endpoint class: one key pinned at the limit is their bug or their growth; many keys pinned is your ceiling set wrong.
- • Retry-After compliance — inter-arrival time after a 429 versus the advertised wait — identifies broken client loops before they become storms.
- • Track limiter rejections vs capacity shedding separately; if 429s rise while the platform is healthy, the contract is tight, and if 5xx rises first, the limiter is set too loose to protect anything.
- • Raising limits is additive and silent; lowering them is a breaking change to every batch job sized against the old number — telemetry first, notice, then the tightening.
- • New dimensions (a per-endpoint-class limit where one global number ruled) ship with headers and docs before enforcement, in observe-only mode, so consumers see the future 429s as warnings first.
- • Migrating from legacy `X-RateLimit-*` to standard `RateLimit-*` headers means emitting both for a deprecation window — header names are parsed by code and are contract surface.
- • Honest headers and documented numbers are commitments: enforcement changes now require the compatibility machinery instead of a config edit.
- • Per-dimension limiters (key × endpoint-class × tier) are real state at the gate — memory, coordination across gateway nodes, and one more system whose failure mode ("limiter down: fail open or closed?") you must choose deliberately.
- • Generous documented limits invite consumers to build right up to them, converting former headroom into contractual floor — the price of predictability.