Rate Limiting & Quota Contracts

Part of the Error Contracts & Resilience Mapping reference. Rate limiting is usually treated as an infrastructure concern that happens to clients. Treated as a contract instead — published headers, a documented 429 body, an honest Retry-After — it becomes something clients can cooperate with, which is the difference between a well-paced integration and a retry storm.

Problem framing

An undocumented limit produces a predictable sequence. A client integrates, works fine in testing, then hits production volume and starts receiving 429s with an empty body. Having no information about how long to wait, its retry loop fires again immediately, the limiter rejects it again, and the client’s own latency budget is consumed by rejections. Support tickets follow, and the answer — “you are allowed 600 requests per minute per key” — was never anywhere the client could read it.

Every part of that is fixable in the contract. Publish the limit, the remaining allowance and the reset moment on every response so a client can pace itself. When you do reject, say why in a machine-readable body, and say when to come back in a header every HTTP library already understands. The rejection then becomes a normal, handled outcome rather than an error, which is exactly the classification discipline described in Retryable vs Non-Retryable Errors.

Choosing the limit model

The model you implement determines what your headers can honestly say. Three are common, and they behave very differently at the window boundary.

Fixed window, sliding window and token bucket behaviour Fixed window allows a double burst across the boundary between two windows. Sliding window smooths the count by weighting the previous window. Token bucket refills continuously and allows a controlled burst up to the bucket capacity. Fixed window — double burst at the boundary window 1 · 100 allowed window 2 · 100 allowed 200 requests in 2 seconds, both windows "compliant" Sliding window — weighted by elapsed time count = current window + previous window × remaining fraction Token bucket —continuousrefill, boundedburst bucket: 100 tokens take 1 request served refill 10 tokens/second, capped at capacity burst allowed to 100 then paced at refillrate

Fixed windows are trivially implementable and let a client legitimately send double the intended rate across a boundary. Sliding windows fix that at the cost of holding more state. Token buckets express “sustained rate plus burst” directly, which is usually what you actually mean, and they make the reset hint approximate rather than exact — an honest header must say so.

Spec definition

Model the limit surface as response headers plus a 429 response reusing the shared problem schema:

# openapi.yaml (OpenAPI 3.1.0) — the rate limit contract
openapi: 3.1.0
info:
  title: Messaging API
  version: "2.3.0"
components:
  headers:
    RateLimitPolicy:
      description: The policy that applied, e.g. "default";q=600;w=60
      schema: { type: string }
      example: '"default";q=600;w=60'
    RateLimit:
      description: Remaining allowance and seconds until reset for the applied policy.
      schema: { type: string }
      example: '"default";r=417;t=23'
    RetryAfter:
      description: RFC 9110 §10.2.3 — seconds to wait before retrying.
      schema: { type: integer, minimum: 0 }
      example: 23
  schemas:
    RateLimitProblem:
      allOf:
        - $ref: "#/components/schemas/Problem"
        - type: object
          required: [policy, limit, window_seconds, retry_after_seconds]
          properties:
            type:                { const: "urn:api:errors:v1:rate-limited" }
            policy:              { type: string, example: default }
            limit:               { type: integer, example: 600 }
            window_seconds:      { type: integer, example: 60 }
            retry_after_seconds: { type: integer, example: 23 }
            scope:               { type: string, enum: [api_key, user, ip, endpoint] }
paths:
  /messages:
    post:
      operationId: sendMessage
      responses:
        "202":
          description: Accepted for delivery.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit:        { $ref: "#/components/headers/RateLimit" }
        "429":
          description: The caller exceeded its quota for this policy.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit:        { $ref: "#/components/headers/RateLimit" }
            Retry-After:      { $ref: "#/components/headers/RetryAfter" }
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/RateLimitProblem" }

Documenting the headers is what makes them reachable from a generated SDK — an undocumented header exists on the wire but not in any client type, so nobody uses it.

Standard alignment

Element Standard Clause Note
429 Too Many Requests RFC 6585 §4 The status code itself
Retry-After RFC 9110 §10.2.3 Seconds or an HTTP-date
503 Service Unavailable RFC 9110 §15.6.4 Server-wide, not per-client
RateLimit / RateLimit-Policy IETF draft (draft-ietf-httpapi-ratelimit-headers) Structured fields; not yet an RFC
X-RateLimit-* de facto convention Widely deployed, unspecified
application/problem+json RFC 9457 §3 The error body media type
Structured field syntax RFC 8941 §3 How the RateLimit value is parsed

The important nuance: the modern RateLimit header fields are a draft, not a finished RFC, and their syntax changed across draft revisions. The stable parts of your contract are 429, Retry-After and the problem body; the header fields are the ergonomic layer on top. That split is explored in RateLimit Header Fields vs X-RateLimit.

Implementation: emitting the contract server-side

// rate-limit.ts — Express middleware emitting the full contract
import type { Request, Response, NextFunction } from "express";

interface Decision {
  allowed: boolean;
  policy: string;      // "default"
  limit: number;       // 600
  window: number;      // 60 seconds
  remaining: number;   // 417
  resetSeconds: number;// 23
  scope: "api_key" | "user" | "ip" | "endpoint";
}

export function rateLimit(check: (req: Request) => Promise<Decision>) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const d = await check(req);

    // Emitted on EVERY response, not only rejections.
    res.setHeader("RateLimit-Policy", `"${d.policy}";q=${d.limit};w=${d.window}`);
    res.setHeader("RateLimit", `"${d.policy}";r=${d.remaining};t=${d.resetSeconds}`);

    if (d.allowed) return next();

    res.setHeader("Retry-After", String(d.resetSeconds));
    res.status(429).type("application/problem+json").json({
      type: "urn:api:errors:v1:rate-limited",
      title: "Rate limit exceeded",
      status: 429,
      detail: `Quota of ${d.limit} requests per ${d.window}s for policy "${d.policy}" is exhausted.`,
      policy: d.policy,
      limit: d.limit,
      window_seconds: d.window,
      retry_after_seconds: d.resetSeconds,
      scope: d.scope,
    });
  };
}

Two details do the heavy lifting. Retry-After is set as an integer number of seconds, which every HTTP client understands without parsing your conventions. And scope tells the client what was limited — an integration hitting a per-endpoint limit can shift work elsewhere, while one hitting a per-key limit must slow down globally.

Implementation: cooperating client-side

A client that only reacts to 429 is already too late. The useful client watches the remaining allowance and paces itself before rejection:

# pacing_client.py — pace requests using the advertised allowance
import asyncio, re, time
import httpx

_RATELIMIT = re.compile(r'r=(\d+).*?t=(\d+)')

class PacedClient:
    """Slows down as the advertised allowance runs low; obeys Retry-After on 429."""

    def __init__(self, client: httpx.AsyncClient, floor: int = 50):
        self._client = client
        self._floor = floor            # start pacing below this many remaining

    async def request(self, method: str, url: str, **kw) -> httpx.Response:
        while True:
            resp = await self._client.request(method, url, **kw)

            if resp.status_code == 429:
                wait = int(resp.headers.get("Retry-After", "1"))
                await asyncio.sleep(wait)
                continue               # a 429 with Retry-After is a schedule, not a failure

            m = _RATELIMIT.search(resp.headers.get("RateLimit", ""))
            if m:
                remaining, reset = int(m.group(1)), int(m.group(2))
                if remaining < self._floor and remaining > 0:
                    # Spread the remaining allowance evenly over the rest of the window.
                    await asyncio.sleep(reset / remaining)
            return resp

Pacing beats retrying because it keeps the client inside its budget instead of spending latency on rejected calls. Where a rejection does happen, the backoff policy should still apply the jitter rules from Configuring Exponential Backoff for 5xx Errors so a fleet of clients does not resynchronise on the same reset boundary.

Edge-case handling

Multiple policies applying at once. A request can be inside its per-key limit but outside a per-endpoint one. Emit one RateLimit-Policy/RateLimit pair per policy (structured fields allow a list) and set Retry-After from the soonest reset the caller must wait for.

Limits behind a shared gateway. When the gateway limits and the service also limits, two independent counters exist and neither header describes the whole truth. Pick one enforcement point, and have the other report only.

Clock skew on absolute resets. A reset expressed as an absolute timestamp is wrong on any client whose clock is off. Prefer relative seconds; if you must expose an absolute time, also send Date so the client can compute the offset.

Client pacing against the advertised allowance Requests are dense while the remaining allowance is high, spread out as the allowance approaches the pacing floor, blocked once exhausted with a Retry-After wait, and dense again after the window resets. time within the 60-second window → r=600…120 · full speed r<50 · pacing: sleep(t / r) 429 · r=0 Retry-After: 23 window reset allowance restored

Validation and testing patterns

Two tests are worth having permanently. The first asserts the headers exist on success, which is the requirement teams forget:

// rate-limit.contract.test.ts
import { describe, it, expect } from "vitest";

describe("rate limit contract", () => {
  it("advertises the allowance on a successful response", async () => {
    const res = await fetch(`${BASE}/messages`, { method: "POST", body: "{}" });
    expect(res.status).toBe(202);
    expect(res.headers.get("RateLimit-Policy")).toMatch(/q=\d+;w=\d+/);
    expect(res.headers.get("RateLimit")).toMatch(/r=\d+;t=\d+/);
  });

  it("rejects with a parseable problem document and Retry-After", async () => {
    let res: Response;
    do { res = await fetch(`${BASE}/messages`, { method: "POST", body: "{}" }); }
    while (res.status !== 429);

    expect(res.headers.get("Content-Type")).toContain("application/problem+json");
    expect(Number(res.headers.get("Retry-After"))).toBeGreaterThanOrEqual(0);
    const body = await res.json();
    expect(body.type).toBe("urn:api:errors:v1:rate-limited");
    expect(body.retry_after_seconds).toBe(Number(res.headers.get("Retry-After")));
  });
});

The final assertion — header and body agreeing — catches the most common production inconsistency, where the two values are computed independently and drift apart under load.

# .spectral.yaml — require the contract wherever a 429 is documented
rules:
  ratelimit-429-needs-retry-after:
    description: A documented 429 must document Retry-After.
    severity: error
    given: "$.paths[*][*].responses['429']"
    then:
      field: headers.Retry-After
      function: truthy

  ratelimit-429-needs-problem-body:
    description: A 429 must return application/problem+json.
    severity: error
    given: "$.paths[*][*].responses['429'].content"
    then:
      field: "application/problem+json"
      function: truthy

SDK generation impact

Documented headers become accessible values; undocumented ones do not exist as far as a generated client is concerned:

  // Generated TypeScript, response envelope
  export interface SendMessageResponse {
    status: number;
    data: MessageAccepted;
+   headers: {
+     "RateLimit-Policy"?: string;   // documented → typed accessor
+     "RateLimit"?: string;
+   };
  }

The 429 is equally consequential: with a documented application/problem+json schema, generators emit a typed error carrying retry_after_seconds, so a caller can branch on it. Without one, the client throws a generic HTTP error and the retry hint is stranded in an untyped response object. The mapping from status code to client behaviour is covered in HTTP Status Code Mapping.

Anti-patterns quick-reference

Anti-pattern Recommended approach
Headers only on 429 Emit allowance headers on every response
429 with an empty body Return application/problem+json with the policy and limits
No Retry-After Always set it; it is the one field every client understands
Absolute reset timestamps only Prefer relative seconds, or also send Date
Using 503 for per-client quota 503 is server-wide; use 429 for a caller’s own quota
One global limit, unnamed Name the policy and scope so the client can act on it
Header and body values computed separately Compute once, render into both
Retrying immediately on 429 Honour Retry-After, then pace against the advertised allowance

Where the limiter belongs

Placement decides what the limit actually protects. A limiter at the edge protects everything behind it, rejects before any body is parsed, and can honestly promise that a rejected request had no side effect — which is what makes a 429 safe to retry automatically. A limiter inside the application can see business context the edge cannot, such as a per-tenant plan or a per-object quota, but by the time it runs the request has already consumed a connection, a worker and a body parse.

Most estates need both, and the failure is running them without coordination: two independent counters, two sets of headers, and neither one describing the whole truth. Pick one enforcement point per policy, let the other report only, and make sure the headers a client sees come from whichever component actually decided.

Edge limiting versus in-application limiting An edge limiter rejects before parsing, protects infrastructure and can guarantee no side effect. An application limiter sees tenant and object context but only after the request has consumed resources. at the edge / gateway inside the application rejects before body parsing protects connections and workers can promise request_applied: false cannot see per-tenant plan detail sees tenant, plan and object context runs after parse and auth can price per operation cheaper request already spent one enforcement point per policy — the other reports, so the headers never disagree

FAQ

Should rate limit headers appear on successful responses or only on 429?

On every response. A header attached only to a rejection tells the client something it already knows. The value of publishing the allowance is that a client can avoid the rejection: seeing that 40 of 600 requests remain with 12 seconds left in the window, it can spread its remaining work instead of burning through the tail and stalling. That is strictly better for both sides — the client keeps its latency budget, and the service sheds load through cooperation rather than rejection.

What is the difference between 429 and 503 for a rate-limited request?

429 means this caller exceeded its quota while the service is otherwise healthy; 503 means the service cannot serve requests right now regardless of who is asking. Clients respond differently to each, which is why the distinction matters: a 429 should make one client slow down against its own budget, while a 503 should trigger backoff across all callers and may open a circuit breaker. Returning 503 for a quota rejection makes a healthy service look broken and can cascade an unnecessary failover.

Is Retry-After needed when the response already carries a reset timestamp?

Yes, and it should be the value you rely on. Retry-After is standardised in RFC 9110 §10.2.3, which means every HTTP library, proxy and generic retry helper already knows how to interpret it without knowing anything about your API. A reset field in a body or a vendor header requires the caller to have read your documentation and written parsing code. Emit both — Retry-After for interoperability and the structured field or body value for precision — and make sure they never disagree.

How should limits be scoped — per key, per user, or per endpoint?

Scope by whatever resource you are actually protecting, and then tell the client which scope it hit. A write-heavy endpoint backed by a single database probably needs a per-endpoint limit; a fair-use policy across a tenant needs a per-key limit; abuse prevention needs a per-IP limit. Most APIs end up with several at once, and that is fine as long as the response names the policy and scope. An unnamed limit leaves a client unable to decide whether to slow down globally or move work to a different call.