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.
The header takes two forms, and the choice has practical consequences for clock-skewed clients:
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.
The same header means slightly different things on the two status codes clients see it with:
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.
Clients need one guard the header itself does not provide:
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