Returning 429 with Problem+JSON

This page belongs to Rate Limiting & Quota Contracts, part of the Error Contracts & Resilience Mapping reference. A 429 with an empty body forces every client to hard-code assumptions about your limiter. A 429 carrying an RFC 9457 problem document tells the client which policy it hit, how long to wait, and whether anything happened — three facts that turn a failure into a schedule.

The decision trigger

Symptom Cause
Clients retry a 429 immediately and get rejected again no Retry-After, no body guidance
Support asked “what is my limit?” for the third time this month limit values exist only in the limiter’s config
A client retried a rejected POST and created two records the limiter rejected after the side effect
Retry-After: 30 while the body says wait 12 seconds header and body computed independently
Client code branches on the detail string no stable type URI to branch on
Generated SDK throws an untyped error on 429 the 429 response has no documented schema

Spec snippet: the response

HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 23
RateLimit-Policy: "per-key";q=600;w=60
RateLimit: "per-key";r=0;t=23

{
  "type": "urn:api:errors:v1:rate-limited",
  "title": "Rate limit exceeded",
  "status": 429,
  "detail": "Quota of 600 requests per 60s for policy \"per-key\" is exhausted.",
  "instance": "/messages",
  "policy": "per-key",
  "scope": "api_key",
  "limit": 600,
  "window_seconds": 60,
  "retry_after_seconds": 23,
  "request_applied": false
}

type, title, status, detail and instance are the RFC 9457 members; policy through request_applied are extension members, which the specification explicitly permits. The one nobody thinks to add is request_applied — an explicit statement that the rejected request had no effect, which is what makes an automatic retry safe. The conventions for these extensions come from Adding Extension Members to Problem Details.

Which parts of a 429 body drive which client decision The type member drives branch selection, retry_after_seconds drives the wait, scope drives whether to slow globally or per endpoint, and request_applied drives whether a retry is safe. The title and detail members are for humans only. problem document type title / detail retry_after_seconds scope limit / window_seconds request_applied instance branch: which handler runs humans: logs, dashboards, support schedule: how long to sleep strategy: global slowdown or one endpoint safety: is an automatic retry allowed

Step-by-step

Step 1 — Reject before side effects

The limiter must run before anything that mutates state. This ordering is what lets you promise request_applied: false:

// app.ts — ordering that makes a 429 safe to retry
app.use(requestId());
app.use(authenticate());        // identity is needed to pick a policy
app.use(rateLimit(checkQuota)); // ← rejects here, before any handler
app.use(express.json());
app.use(routes);                // handlers only ever see admitted requests

Placing the limiter after body parsing is a common and expensive mistake: a large rejected upload has already been read into memory, so the limiter protects the handler but not the resource actually under pressure.

Step 2 — Build the body from one computed decision

// rate-limit-problem.ts — one value renders both header and body
import type { Response } from "express";

export interface QuotaRejection {
  policy: string;
  scope: "api_key" | "user" | "ip" | "endpoint";
  limit: number;
  windowSeconds: number;
  retryAfterSeconds: number;
  instance: string;
}

export function sendRateLimited(res: Response, r: QuotaRejection) {
  res.setHeader("Retry-After", String(r.retryAfterSeconds));   // same source…
  res.status(429).type("application/problem+json").json({
    type: "urn:api:errors:v1:rate-limited",
    title: "Rate limit exceeded",
    status: 429,
    detail: `Quota of ${r.limit} requests per ${r.windowSeconds}s for policy `
          + `"${r.policy}" is exhausted.`,
    instance: r.instance,
    policy: r.policy,
    scope: r.scope,
    limit: r.limit,
    window_seconds: r.windowSeconds,
    retry_after_seconds: r.retryAfterSeconds,                  // …as this
    request_applied: false,
  });
}

Step 3 — Handle it on the client as a schedule, not a failure

# handle_429.py — treat a rate limit rejection as a delay instruction
import asyncio
import httpx

RATE_LIMITED = "urn:api:errors:v1:rate-limited"

async def send_with_quota_awareness(client: httpx.AsyncClient, req: httpx.Request,
                                    max_waits: int = 3) -> httpx.Response:
    for _ in range(max_waits + 1):
        resp = await client.send(req)
        if resp.status_code != 429:
            return resp

        problem = resp.json() if "problem+json" in resp.headers.get("content-type", "") else {}
        if problem.get("type") != RATE_LIMITED:
            return resp                       # a different 429 semantic — do not guess

        if problem.get("request_applied") is True:
            return resp                       # side effect happened; retrying would duplicate

        delay = int(resp.headers.get("Retry-After")
                    or problem.get("retry_after_seconds", 1))
        await asyncio.sleep(delay)
    return resp

The request_applied check is the safety interlock. Without an explicit statement from the server, a client cannot know whether a rejected POST was partially processed, and the conservative behaviour — not retrying — costs throughput on every rate-limited write. Where a rejection can follow a side effect, the caller needs the idempotency machinery from Idempotency Key Implementation instead.

Step 4 — Prove header and body agree

// 429-consistency.test.ts
it("keeps Retry-After and retry_after_seconds identical", async () => {
  const res = await exhaustQuota();
  expect(res.status).toBe(429);

  const header = Number(res.headers.get("Retry-After"));
  const body = await res.json();

  expect(body.retry_after_seconds).toBe(header);
  expect(body.request_applied).toBe(false);
  expect(body.type).toBe("urn:api:errors:v1:rate-limited");
});
Rejected, scheduled, retried The client sends a request, receives a 429 with Retry-After 23 and request_applied false, waits 23 seconds, sends the same request again and receives 202 Accepted. client API + limiter POST /messages 429 · Retry-After: 23 · request_applied: false sleep 23s no duplicate risk POST /messages (same body) 202 Accepted · RateLimit: r=599

The ordering of the middleware stack is what makes the body’s promises true:

Where the limiter must sit in the stack Request identification and authentication run first so a policy can be chosen, then the limiter rejects, and only admitted requests reach body parsing and handlers. reject before anything is consumed request id correlation only no state touched authenticate identity → policy needed to pick a quota rate limit rejects here request_applied: falseholds parse + handle admitted requests only side effects begin a limiter placed after body parsing protects the handler but not the resource under pressure

Standard compliance

Element Clause Requirement
429 Too Many Requests RFC 6585 §4 Response “MAY include a Retry-After”
Retry-After RFC 9110 §10.2.3 Delay-seconds or HTTP-date
application/problem+json RFC 9457 §3 Media type for the body
type default RFC 9457 §3.1.1 Absent type means about:blank
Extension members RFC 9457 §3.2 Additional members are permitted
status must match RFC 9457 §3.1.2 Body status mirrors the HTTP status

RFC 9457 obsoletes RFC 7807 without changing these fundamentals; the notable addition is guidance on registering problem types, which is what an internal registry — see Building a Machine-Readable Error Registry — implements in-house.

SDK / codegen downstream effect

  // Generated TypeScript error handling
  try {
    await client.messages.sendMessage({ body });
  } catch (e) {
-   // Undocumented 429: generic error, retry hint stranded in a raw response
-   if (e instanceof ResponseError && e.response.status === 429) {
-     const raw = await e.response.text();   // parse it yourself
-   }
+   // Documented 429 + problem schema: typed, with the quota state attached
+   if (e instanceof RateLimitedError) {
+     await sleep(e.problem.retry_after_seconds * 1000);
+   }
  }

The typed branch only exists when the 429 response declares a schema in the document, which is why the response belongs in the spec and not only in the limiter. Documenting Rate Limits in OpenAPI covers the declaration in detail.

Common mistakes

Mistake Correct approach
Empty 429 body Return application/problem+json with quota members
Retry-After omitted Always set it; derive it from the same value as the body
Limiter placed after body parsing or handlers Reject at the edge so request_applied: false is true
type string changed to improve wording type is an identifier; change title/detail instead
Returning 403 or 503 for quota exhaustion 429 is the specified code for a caller’s own quota
Echoing the caller’s request body in the problem Report the policy and window; avoid leaking payloads

FAQ

Is a 429 response body safe to retry automatically?

It is when the limiter rejects before any side effect, which is the normal edge-level implementation and what you should guarantee. Say so explicitly — both in your documentation and in the body, via a member such as request_applied: false — because a client cannot otherwise distinguish “we never processed this” from “we processed it and then rejected the response”. Where a rejection can occur after partial processing, such as a limiter inside a handler, do not claim safety; instead require an idempotency key so the retry is deduplicated server-side.

What should the type URI of a rate limit problem be?

A stable identifier drawn from your error registry, such as urn:api:errors:v1:rate-limited, or an HTTPS URI that resolves to documentation. The scheme matters less than the stability: clients branch on this value, so it must never be reworded, re-cased, or re-scoped once published. Keep the human-readable wording in title and detail, where changing it breaks nothing, and treat a change to type as a breaking change to the error contract.

Should the 429 body tell the client which requests were counted?

No — report the policy, its scope and its window rather than an audit trail. A client needs enough information to change behaviour: which limit it hit, how long until the window resets, and whether the limit is global to its key or specific to one endpoint. An enumeration of counted requests is large, expensive to produce under exactly the load conditions where the limiter is active, and in a multi-tenant system it risks exposing activity from other credentials sharing a limit.