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.
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");
});
The ordering of the middleware stack is what makes the body’s promises true:
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.
Related
- Rate Limiting & Quota Contracts — up-link: the section covering limit models, headers and client pacing
- Error Contracts & Resilience Mapping — up-link: the parent reference for failure contracts
- RateLimit Header Fields vs X-RateLimit — the headers that accompany this body
- Type URI Conventions for Problem Details — choosing the identifier clients branch on
- Using the Retry-After Header for 429 and 503 — the header’s semantics across both status codes