Securityrate limiting429retry-afterthrottlingheaders

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.

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
What does a client that hits the limit actually experience — and does the contract give it enough information to slow down correctly instead of retrying itself into a ban?
Consumers
SDK authors writing the backoff loop every user inherits, integration developers sizing batch jobs against documented ceilings, and the provider's on-call, for whom the limiter is the difference between "one noisy tenant throttled" and "everyone down".
The promise
Documented limits per dimension, a `429` with `Retry-After` and standard rate-limit headers on every throttled response, headers on *successful* responses so clients can pace before failing, and stable limiter identity (key, not IP) that matches how consumers are billed.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

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.

A rejection a client can obey mechanically
Request
POST /v1/messages HTTP/1.1
Host: api.example.com
Authorization: Bearer sk_live_51Nx…

{ "channel": "ch_9", "text": "…" }
Response
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 rate

Dimensions: 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.

Limiter dimensions and who each one punishes
Keyed onProtects againstFails whenContract role
API key / tokenOne integration's runaway loop or spikeOne tenant runs many keys to multiply quotaPrimary documented dimension — matches billing and revocation
User / tenantAggregate abuse across a tenant's keysMulti-tenant apps funnel many users through one tenantThe fairness dimension for per-seat products
IP addressUnauthenticated abuse: signup, login, scrapingCorporate NAT (false positives), botnets (false negatives)Edge defense only — never the documented tenant limit
Endpoint classExpensive operations starving cheap onesClass boundaries drawn wrong — one hot endpoint drags its classCost 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.

Undeclared limit, unhelpful rejection
1# docs: (nothing about limits)
2
3HTTP/1.1 403 Forbidden
4{ "error": "blocked" }
5
6# no Retry-After → client guesses (usually: retry now)
7# 403 → SDKs treat it as auth failure, log the user out
8# limit keyed on IP → the whole office is banned together
9# support learns the real numbers one ticket at a time
Declared numbers, mechanical recovery, paced success path
1# docs: 100 req/min per key · 10/min for POST /exports
2# 429 + Retry-After on rejection
3# RateLimit-* headers on every response
4
5HTTP/1.1 200 OK
6RateLimit-Limit: 100
7RateLimit-Remaining: 12
8RateLimit-Reset: 31
9
10# SDK behavior (shipped, documented):
11# remaining low → pace proactively
12# 429 → sleep max(Retry-After, backoff) + jitter
13# sustained 429 → surface to caller, suggest batching

The 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 Remaining pace 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.

  1. 1
    Provider → contract: ships with no declared limits; capacity is the limiter and nobody knows it.
  2. 2
    Integration → API: a partner's nightly sync fans out to 200 parallel workers because nothing said not to.
  3. 3
    Platform → tenants: the database saturates; every tenant's latency spikes — the noisy neighbor is invisible in the contract because the contract never defined "too much".
  4. 4
    Provider → firefight: ops hand-blocks the partner's IP range; their integration hard-fails with connection errors that look like an outage on their side.
  5. 5
    Both sides → aftermath: the partner's retry logic, written against no documented behavior, hammers the endpoint the moment the block lifts — and the cycle repeats.
What breaks
  • 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.

Design the contract
  • • 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.
Observe in production
  • • 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.
Evolve without breaking
  • • 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.
What it costs
  • • 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.

Misconceptions

Claim
“Rate limiting is an infrastructure concern, invisible to API design.”
Reality
The enforcement is infrastructure; the *experience* — which status, which headers, which dimensions, which numbers — is contract that every SDK's retry loop and every batch job's sizing is written against. Skipping the contract half is how correct limiters cause client-side outages.
Claim
“Returning 429 tells attackers our capacity.”
Reality
The 429 reflects the caller's *policy* ceiling, not your capacity — a per-key limit says nothing about the fleet behind it. Meanwhile the silence hurts your paying integrations daily. Abuse defense at the unauthenticated edge can be quieter; documented tenant limits should be loud.
Claim
“Clients should just retry until it works.”
Reality
Un-paced retries against a limiter are a self-inflicted denial of service: each 429 (often) counts against the bucket, so the loop pins itself at zero. The contract's Retry-After exists precisely so the client's correct behavior is arithmetic instead of hope.

Apply it