Configuring Exponential Backoff for 5xx Errors

This page is part of the Retryable vs Non-Retryable Errors reference, itself a section of the Error Contracts & Resilience Mapping guide.

Exponential backoff is the standard mechanism for spreading client retries over increasing intervals so a struggling upstream has room to recover. Without it, every in-flight client retries simultaneously the moment a 503 or 504 arrives — turning a brief overload spike into a self-reinforcing storm. This page covers the exact parameters, code, and contract fixtures you need to ship a correct backoff policy.


When You Need This

Backoff misconfiguration typically surfaces in one of three ways:

Signal What it indicates
Retry-attempt timestamps cluster within <500 ms windows Jitter is absent or the formula produces near-zero values
Upstream 502/504 rates rise in lock-step with client retry bursts Thundering-herd amplification — clients synchronise after a common outage event
POST requests appear duplicated in the database Retry logic is applied without an idempotency key guard
Retry-After headers present in responses but client ignores them SDK or interceptor lacks header-parsing logic for 5xx responses

If you see any of these, the sections below give you a reproducible path from a blank terminal to a validated policy.


Spec Snippet

Annotate every retryable operation in your OpenAPI 3.1.0 spec with an x-backoff-strategy extension. This gives SDK generators and API gateways a machine-readable source of truth for retry parameters.

openapi: "3.1.0"
info:
  title: Payments API
  version: "1.0.0"
paths:
  /v1/transactions:
    post:
      operationId: createTransaction
      x-retryable: true
      x-backoff-strategy:
        base_delay_ms: 1000
        multiplier: 2
        max_retries: 3
        jitter: "full"          # "full" | "equal" | "none"
        max_delay_ms: 10000
        retry_on:
          - 429
          - 500
          - 502
          - 503
          - 504
      responses:
        "202":
          description: Transaction accepted
        "503":
          description: Service temporarily unavailable
          headers:
            Retry-After:
              schema:
                type: integer
              description: Seconds until the service expects to recover

The jitter: "full" value selects the AWS-recommended full-jitter formula (random(0, min(cap, base * 2^attempt))), which gives the best load distribution under high concurrency.


Step-by-Step Resolution Guide

Step 1 — Classify which 5xx codes are retryable

Not every server error warrants a retry. Blindly retrying 501 Not Implemented wastes quota and masks a genuine contract bug.

Status Retryable? Reason
500 Conditional Retry only when the error body confirms a transient fault (e.g. DB connection timeout). Structural 500s are not retryable.
502 Yes Bad Gateway — proxy could not reach the upstream. Inherently transient.
503 Yes Service Unavailable — explicitly signals temporary overload. Always honour Retry-After.
504 Yes Gateway Timeout — upstream too slow; a later attempt is usually safe.
501 No Not Implemented — a structural capability gap. Retrying changes nothing.
505 No HTTP Version Not Supported — a protocol mismatch. Retrying changes nothing.

Map this table to the retry_on list in your spec extension and in your client implementation.

Step 2 — Implement full-jitter backoff in TypeScript

The following Axios interceptor reads the retry parameters from the request config so the values stay consistent with the spec extension.

import axios, { AxiosInstance, AxiosRequestConfig, AxiosError } from "axios";

const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]);
const BASE_DELAY_MS = 1_000;
const MULTIPLIER = 2;
const MAX_DELAY_MS = 10_000;
const MAX_RETRIES = 3;

interface RetryConfig extends AxiosRequestConfig {
  _attempt?: number;
}

function fullJitterDelay(attempt: number): number {
  const exponential = BASE_DELAY_MS * Math.pow(MULTIPLIER, attempt);
  const cap = Math.min(exponential, MAX_DELAY_MS);
  return Math.random() * cap; // uniform random in [0, cap]
}

function parseRetryAfterMs(header: string | undefined): number | null {
  if (!header) return null;
  const seconds = parseInt(header, 10);
  if (!isNaN(seconds) && seconds > 0) return seconds * 1_000;
  const date = Date.parse(header);
  if (!isNaN(date)) return Math.max(0, date - Date.now());
  return null;
}

export function attachRetryInterceptor(client: AxiosInstance): void {
  client.interceptors.response.use(null, async (error: AxiosError) => {
    const config = error.config as RetryConfig | undefined;
    if (!config) return Promise.reject(error);

    const status = error.response?.status ?? 0;
    const attempt = config._attempt ?? 0;

    if (!RETRYABLE_STATUSES.has(status) || attempt >= MAX_RETRIES) {
      return Promise.reject(error);
    }

    // Respect server-provided Retry-After before falling back to algorithm
    const retryAfterHeader = error.response?.headers["retry-after"] as string | undefined;
    const delayMs = parseRetryAfterMs(retryAfterHeader) ?? fullJitterDelay(attempt);

    await new Promise<void>((resolve) => setTimeout(resolve, delayMs));

    return client.request({ ...config, _attempt: attempt + 1 });
  });
}

Step 3 — Implement full-jitter backoff in Python

import asyncio
import random
import time
from typing import Optional

import httpx

RETRYABLE_STATUSES = {429, 500, 502, 503, 504}
BASE_DELAY_S = 1.0
MULTIPLIER = 2
MAX_DELAY_S = 10.0
MAX_RETRIES = 3


def full_jitter_delay(attempt: int) -> float:
    """Return a uniformly random delay in [0, min(cap, base * multiplier^attempt)]."""
    cap = min(MAX_DELAY_S, BASE_DELAY_S * (MULTIPLIER ** attempt))
    return random.uniform(0, cap)


def parse_retry_after(header: Optional[str]) -> Optional[float]:
    """Return seconds to wait from a Retry-After header, or None if absent/unparseable."""
    if not header:
        return None
    try:
        seconds = float(header)
        if seconds > 0:
            return seconds
    except ValueError:
        pass
    # HTTP-date format
    from email.utils import parsedate_to_datetime
    try:
        dt = parsedate_to_datetime(header)
        remaining = dt.timestamp() - time.time()
        return max(0.0, remaining)
    except Exception:
        return None


async def request_with_backoff(
    client: httpx.AsyncClient,
    method: str,
    url: str,
    **kwargs,
) -> httpx.Response:
    for attempt in range(MAX_RETRIES + 1):
        response = await client.request(method, url, **kwargs)
        if response.status_code not in RETRYABLE_STATUSES or attempt == MAX_RETRIES:
            response.raise_for_status()
            return response

        retry_after = parse_retry_after(response.headers.get("retry-after"))
        delay = retry_after if retry_after is not None else full_jitter_delay(attempt)
        await asyncio.sleep(delay)

    raise RuntimeError("Unreachable")

Step 4 — Parse and honour Retry-After

Retry-After can be either a delta-seconds integer or an HTTP-date string (RFC 7231 § 7.1.3). Both implementations above handle both formats. The server-provided value must always win over the algorithm — it reflects actual upstream capacity and scheduled maintenance windows.

HTTP/1.1 503 Service Unavailable
Retry-After: 30
Content-Type: application/problem+json

{
  "type": "https://api.example.com/errors/service-unavailable",
  "status": 503,
  "title": "Service Temporarily Unavailable",
  "detail": "Upstream dependency degraded. Retry after the indicated interval."
}

The error body follows the RFC 7807 Problem+JSON contract, which your client should validate before deciding whether to retry.

Step 5 — Enforce the policy with contract tests

Pin the retry behaviour in CI so generator updates or dependency bumps cannot silently regress it.

// tests/retry-backoff.contract.test.ts
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
import { attachRetryInterceptor } from "../src/retry";
import axios from "axios";

const server = setupServer();
beforeAll(() => server.listen());
afterAll(() => server.close());

test("honours Retry-After header and does not fall back to jitter", async () => {
  let callCount = 0;
  const timestamps: number[] = [];

  server.use(
    http.get("http://test/resource", () => {
      callCount++;
      timestamps.push(Date.now());
      if (callCount < 3) {
        return new HttpResponse(null, {
          status: 503,
          headers: { "Retry-After": "1" },
        });
      }
      return HttpResponse.json({ ok: true });
    })
  );

  const client = axios.create({ baseURL: "http://test" });
  attachRetryInterceptor(client);

  await client.get("/resource");

  expect(callCount).toBe(3);
  // Each wait should be ~1000 ms; allow 200 ms tolerance
  expect(timestamps[1] - timestamps[0]).toBeGreaterThanOrEqual(800);
  expect(timestamps[2] - timestamps[1]).toBeGreaterThanOrEqual(800);
});

test("does not retry on 501", async () => {
  server.use(
    http.get("http://test/unsupported", () =>
      new HttpResponse(null, { status: 501 })
    )
  );

  const client = axios.create({ baseURL: "http://test" });
  attachRetryInterceptor(client);

  await expect(client.get("/unsupported")).rejects.toMatchObject({
    response: { status: 501 },
  });
});

Backoff Timing Diagram

The SVG below illustrates three retry attempts with full-jitter delay: each wait is a random value between zero and the exponential cap, so simultaneous clients spread their retries across the window rather than piling up at the same instant.

Exponential backoff with full jitter Timeline showing three retry attempts. The first attempt fails immediately. After a random wait within [0, 1 s] the first retry fires; after a random wait within [0, 2 s] the second retry fires; after a random wait within [0, 4 s] the third retry succeeds. The jitter window widens with each attempt. 0 s ~8 s Attempt 1 503 [0, 1 s] Attempt 2 503 [0, 2 s] Attempt 3 503 [0, 4 s] Success 200 Jitter window (random delay sampled from this range) Cap boundary (base × 2^attempt)

RFC and Standard Compliance

The backoff strategy above aligns with these specifications:

Clause Requirement
RFC 7231 § 6.6.4 503 Service Unavailable — server may include Retry-After; clients should honour it
RFC 7231 § 7.1.3 Retry-After format: delta-seconds integer or HTTP-date string
RFC 6585 § 4 429 Too Many Requests — same Retry-After semantics apply
RFC 7807 Error body format for 503/502 responses; type URI identifies the fault class

The retry_on list in your spec extension should include 429 alongside the 5xx codes above because 429 is structurally identical in backoff treatment: the server is overwhelmed and has provided recovery guidance.


Idempotency and Safety Implications

Automatic retry is safe by default only for idempotent HTTP methods (GET, PUT, DELETE). For POST and PATCH, a retry that arrives after the server completed the first attempt silently duplicates the operation. Guard these with an idempotency key — a client-generated UUID sent in a header such as Idempotency-Key: <uuid> — so the server can deduplicate on the key rather than the body. Without that guarantee, scope your retry interceptor to exclude POST unless the endpoint explicitly opts in via the x-retryable: true extension.


SDK and Codegen Downstream Effect

OpenAPI generators do not emit retry logic from x-backoff-strategy by default. The generated client looks like this:

// Generated (no retry awareness)
async function createTransaction(body: TransactionRequest): Promise<Transaction> {
  const response = await fetch("/v1/transactions", {
    method: "POST",
    body: JSON.stringify(body),
  });
  if (!response.ok) throw new ApiError(response);
  return response.json();
}

After patching the Mustache template or wrapping the client with the interceptor above, the diff is:

-async function createTransaction(body: TransactionRequest): Promise<Transaction> {
-  const response = await fetch("/v1/transactions", { method: "POST", body: JSON.stringify(body) });
-  if (!response.ok) throw new ApiError(response);
-  return response.json();
-}
+async function createTransaction(body: TransactionRequest): Promise<Transaction> {
+  return requestWithBackoff("POST", "/v1/transactions", body, {
+    retryOn: [429, 500, 502, 503, 504],
+    maxRetries: 3,
+    baseDelayMs: 1000,
+    jitter: "full",
+    maxDelayMs: 10000,
+  });
+}

Add a CI lint step that greps the generated output for retryOn and fails the build if it is absent:

grep -q "retryOn" src/generated/client.ts || \
  (echo "Generated client is missing retry configuration" && exit 1)

Common Mistakes

Mistake Correct approach
Fixed delay between retries (e.g. sleep(2000)) Exponential multiplier with full-jitter so concurrent clients spread across the window
Retrying all 5xx including 501 and 505 Scope retries to 500 (conditional), 502, 503, 504, and 429 only
Ignoring Retry-After header Parse both delta-seconds and HTTP-date formats; use the server value when present
No max_retries cap Hard limit of 3–5 attempts; beyond this, drop to a cached response or surface the error
Applying retry to POST without an idempotency key Require Idempotency-Key on all POST endpoints before enabling automatic retries

FAQ

Should Retry-After override exponential backoff?

Yes. A server-provided Retry-After reflects actual upstream capacity. Honour it unconditionally and fall back to the exponential algorithm only when the header is absent or malformed. Overriding the server’s directive with a shorter calculated delay undermines capacity planning and can prolong an outage.

Is it safe to retry non-idempotent POST requests on 5xx?

Only if the endpoint accepts an idempotency key. Without a key, a retry that arrives after the server processed the original request duplicates the operation. Scope automatic retries to GET, PUT, and DELETE by default; opt POST endpoints in individually, paired with an idempotency key policy.

How many retries are appropriate before giving up?

Three to five attempts is the practical ceiling. With full-jitter backoff starting at 1 second and a 2× multiplier, five retries can span up to roughly 30 seconds of elapsed time — already at the edge of acceptable latency for synchronous API calls. Beyond five, the success probability for genuinely degraded upstreams drops below 10 %, and the cumulative delay strains end-user SLAs. Prefer circuit-breaker escalation over additional retries.