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-After response header field indicates how long the user agent ought to wait before making a follow-up request. It takes one of two forms: an HTTP-date (the absolute time after which the client may retry) or a non-negative decimal integer delay-seconds (the number of seconds to wait). RFC 9110 explicitly permits Retry-After on 503 (Service Unavailable) and 3xx (redirection) responses, and it is widely used on 429 (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.