Using the Retry-After Header for 429 and 503
This guide sits under Retryable vs Non-Retryable Errors, part of the Error Contracts & Resilience Mapping reference. It covers how a server should emit Retry-After on 429 Too Many Requests and 503 Service Unavailable, and how a well-behaved client should honor that hint while still applying exponential backoff for 5xx errors and jitter.
The symptom that triggers this decision
You ship a client that blindly retries on 429 and 503. Under load, every client wakes up at the same interval, hammers the recovering server, and turns a soft throttle into a thundering-herd outage. Or the opposite: your client backs off on a fixed schedule and ignores the precise Retry-After: 2 the server sent, wasting throughput by waiting 30 seconds when the server said 2. Both failures come from treating retry timing as a client-only concern. The server already knows when it will be ready — Retry-After is how it tells you.
Retry-After applies to exactly two situations in practice: a 429 where the rate-limit window will reset at a known time, and a 503 where the service is temporarily down (deploy, overload shedding, or scheduled maintenance) and expects to recover. It is a retryable signal — distinct from a 400 or 422, which no amount of waiting will fix.
Retry-After per RFC 9110 §10.2.3
Standard callout — RFC 9110 §10.2.3. The
Retry-Afterresponse header field indicates how long the user agent ought to wait before making a follow-up request. It takes one of two forms: anHTTP-date(the absolute time after which the client may retry) or a non-negative decimal integerdelay-seconds(the number of seconds to wait). RFC 9110 explicitly permitsRetry-Afteron503 (Service Unavailable)and3xx (redirection)responses, and it is widely used on429 (Too Many Requests)as defined by RFC 6585.
Two valid wire forms:
HTTP/1.1 429 Too Many Requests
Retry-After: 2
HTTP/1.1 503 Service Unavailable
Retry-After: Wed, 08 Jul 2026 14:30:00 GMT
The HTTP-date MUST be an IMF-fixdate in GMT. The delay-seconds form is a plain integer count of seconds. Prefer delay-seconds for throttling and transient outages: it carries no dependency on the client’s clock, so it is immune to clock skew. Reserve the date form for genuinely scheduled events with a fixed absolute end.
OpenAPI 3.1 contract
Document Retry-After as a response header on every operation that can throttle or shed load, so it appears in generated SDKs and mock servers:
# openapi.yaml (OpenAPI 3.1.0)
components:
headers:
RetryAfter:
description: >
Seconds to wait before retrying (delay-seconds form), or an
HTTP-date after which the request may be retried. Present on
429 and 503 responses.
required: true
schema:
oneOf:
- type: integer
minimum: 0
example: 2
- type: string
format: http-date
example: "Wed, 08 Jul 2026 14:30:00 GMT"
responses:
TooManyRequests:
description: Rate limit exceeded.
headers:
Retry-After: { $ref: '#/components/headers/RetryAfter' }
X-RateLimit-Limit: { schema: { type: integer }, description: Requests allowed per window. }
X-RateLimit-Remaining: { schema: { type: integer }, description: Requests remaining in the current window. }
X-RateLimit-Reset: { schema: { type: integer }, description: Unix epoch seconds when the window resets. }
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
Setting Retry-After on the server
Step 1 — Compute the delay from the throttle window
For a fixed-window or token-bucket limiter, Retry-After is the number of seconds until the window resets (or until enough tokens are available). Round up so you never tell a client to retry a fraction of a second too early.
Node (Express):
function rateLimit(req, res, next) {
const { allowed, resetEpochSec, limit, remaining } = bucket.check(req.ip);
if (allowed) {
res.set('X-RateLimit-Limit', String(limit));
res.set('X-RateLimit-Remaining', String(remaining));
return next();
}
const retryAfter = Math.max(1, Math.ceil(resetEpochSec - Date.now() / 1000));
res.set('Retry-After', String(retryAfter));
res.set('X-RateLimit-Limit', String(limit));
res.set('X-RateLimit-Remaining', '0');
res.set('X-RateLimit-Reset', String(resetEpochSec));
res.status(429).type('application/problem+json').json({
type: 'https://api-contract.com/problems/rate-limited',
title: 'Rate limit exceeded',
status: 429,
detail: `Retry after ${retryAfter}s.`,
});
}
Step 2 — Use the date form only for scheduled 503s
Python (FastAPI):
from datetime import datetime, timezone
from email.utils import format_datetime
from fastapi import FastAPI, Response
app = FastAPI()
MAINTENANCE_END = datetime(2026, 7, 8, 14, 30, tzinfo=timezone.utc)
@app.get("/v1/reports")
def reports(response: Response):
if in_maintenance():
# HTTP-date form: absolute, known end time.
response.headers["Retry-After"] = format_datetime(MAINTENANCE_END, usegmt=True)
response.status_code = 503
return {"type": "https://api-contract.com/problems/maintenance",
"title": "Service temporarily unavailable", "status": 503}
return {"data": []}
format_datetime(..., usegmt=True) emits a compliant IMF-fixdate (Wed, 08 Jul 2026 14:30:00 GMT). Never hand-format the date; locale and timezone bugs are the usual cause of malformed values.
Honoring Retry-After on the client
The client’s job is to parse both forms, treat the result as a floor, and blend it with exponential backoff and jitter so retries never fire earlier than the server asked and never synchronize into a herd.
Step 3 — Parse both forms into a delay
Node:
function parseRetryAfter(headerValue, nowMs = Date.now()) {
if (headerValue == null) return null;
const trimmed = String(headerValue).trim();
if (/^\d+$/.test(trimmed)) {
return Number(trimmed) * 1000; // delay-seconds -> ms
}
const dateMs = Date.parse(trimmed); // HTTP-date -> epoch ms
if (Number.isNaN(dateMs)) return null;
return Math.max(0, dateMs - nowMs);
}
Step 4 — Combine with backoff, jitter, and a cap
Compute an exponential backoff, apply full jitter, then take the larger of the jittered backoff and the server’s Retry-After. This guarantees the server hint is a lower bound while jitter still de-synchronizes clients whose backoff exceeds the hint.
const BASE_MS = 500, CAP_MS = 30_000, MAX_ATTEMPTS = 5;
async function requestWithRetry(doRequest) {
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
const res = await doRequest();
if (res.status !== 429 && res.status !== 503) return res;
const serverFloor = parseRetryAfter(res.headers.get('retry-after')) ?? 0;
const expo = Math.min(CAP_MS, BASE_MS * 2 ** attempt);
const jittered = Math.random() * expo; // full jitter
const waitMs = Math.min(CAP_MS, Math.max(serverFloor, jittered));
if (attempt === MAX_ATTEMPTS - 1) return res; // out of budget
await new Promise((r) => setTimeout(r, waitMs));
}
}
Python (httpx):
import asyncio, random
from email.utils import parsedate_to_datetime
from datetime import datetime, timezone
BASE, CAP, MAX_ATTEMPTS = 0.5, 30.0, 5
def parse_retry_after(value: str | None) -> float | None:
if not value:
return None
value = value.strip()
if value.isdigit():
return float(value)
try:
when = parsedate_to_datetime(value)
except (TypeError, ValueError):
return None
return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())
async def request_with_retry(client, method, url, **kwargs):
for attempt in range(MAX_ATTEMPTS):
resp = await client.request(method, url, **kwargs)
if resp.status_code not in (429, 503):
return resp
floor = parse_retry_after(resp.headers.get("retry-after")) or 0.0
expo = min(CAP, BASE * 2 ** attempt)
jittered = random.uniform(0, expo) # full jitter
wait = min(CAP, max(floor, jittered))
if attempt == MAX_ATTEMPTS - 1:
return resp
await asyncio.sleep(wait)
return resp
How Retry-After relates to X-RateLimit-* headers
Retry-After and the X-RateLimit-* family answer different questions. The rate-limit headers describe the state of the quota; Retry-After is the instruction for when to come back.
| Header | Role | Client action |
|---|---|---|
Retry-After |
Authoritative wait instruction | Wait at least this long before retrying |
X-RateLimit-Limit |
Requests allowed per window | Size client-side pacing / concurrency |
X-RateLimit-Remaining |
Requests left in the window | Pre-emptively slow down as it nears 0 |
X-RateLimit-Reset |
Epoch seconds when the window resets | Diagnostics; usually equals Retry-After |
When Retry-After and X-RateLimit-Reset disagree, Retry-After wins for the actual wait — it is the normative field. Keep the server code that emits both derived from the same window value so they seldom diverge. Note that X-RateLimit-* are convention, not a standard; the IETF RateLimit and RateLimit-Policy fields are the emerging standardized replacement, but Retry-After remains the interoperable instruction across all of them.
Idempotency and safety implications
Retrying is only safe when the request is idempotent. GET, HEAD, PUT, and DELETE are idempotent by RFC 9110 semantics, so a Retry-After-driven retry cannot cause duplicate side effects. POST is not idempotent — an automatic retry after a 429/503 risks creating two resources if the first request actually reached the server before the throttle. Gate POST retries behind an idempotency key so the server can deduplicate replays; see Handling Idempotency Key Conflicts. A 429 almost always means the request was rejected before processing, so it is generally safe to retry, but do not assume the same of a 503 behind a proxy that may have forwarded the body.
Retry-After responses themselves should carry Cache-Control: no-store — a throttle or maintenance response must never be cached and replayed to other clients as if it were fresh content.
SDK and codegen downstream effect
Because Retry-After is declared in the OpenAPI response headers, a generated SDK can surface it as a typed field and drive its built-in retry policy from it rather than a hard-coded schedule:
// Generated client retry hook
client.onRetryableStatus((res, attempt) => {
const floorMs = res.retryAfterMs ?? 0; // parsed from Retry-After
const backoffMs = Math.min(30_000, 500 * 2 ** attempt);
return Math.max(floorMs, Math.random() * backoffMs);
});
Generators that omit Retry-After from the header set produce SDKs that fall back to blind backoff, ignoring the server’s own timing. Assert the header’s presence on 429/503 responses in a contract test so it survives spec regeneration.
Common mistakes
| Mistake | Correct approach |
|---|---|
Client ignores Retry-After and uses only fixed backoff |
Treat Retry-After as a floor; wait max(serverFloor, jitteredBackoff) |
Emitting an HTTP-date in local time or a non-GMT zone |
Always send an IMF-fixdate in GMT (format_datetime(dt, usegmt=True)) |
| Retrying before the specified delay to “save time” | Never retry earlier than Retry-After; earlier retries prolong the outage |
Applying no cap, so an HTTP-date far in the future blocks forever |
Clamp the parsed delay to a maximum wait and a bounded attempt count |
Auto-retrying non-idempotent POST on 503 without a key |
Require an idempotency key before retrying unsafe methods |
FAQ
Should Retry-After use delay-seconds or an HTTP-date?
Prefer delay-seconds for 429 and transient 503 responses because it is a relative count and therefore immune to client clock skew. Use an HTTP-date only for scheduled maintenance with a known absolute end time, and format it as a full RFC 9110 IMF-fixdate in GMT. Mixing a skewed client clock with a date form is the most common source of clients that retry far too early or far too late.
What happens if Retry-After and X-RateLimit-Reset disagree?
Retry-After is the authoritative instruction; X-RateLimit-Reset is informational diagnostics. When they differ, honor Retry-After for the actual wait and use the rate-limit headers only for observability and client-side pacing. Keep the server logic that generates both values derived from the same window so they rarely diverge in the first place.
Should a client ever ignore Retry-After and retry sooner?
No. Retrying before the server-specified delay defeats the header’s purpose and can extend an outage by re-loading a recovering service. Treat Retry-After as a floor: wait at least that long, optionally longer if your own exponential backoff computes a larger value, but never shorter.
Related
- Retryable vs Non-Retryable Errors — the parent reference classifying which status codes are worth retrying
- Error Contracts & Resilience Mapping — the top-level reference on error modeling and resilience
- Configuring Exponential Backoff for 5xx Errors — the backoff and jitter algorithm this header blends into
- Adding Extension Members to Problem Details — surfacing retry hints inside a problem+json body