Retryable vs Non-Retryable API Errors

Part of the Error Contracts & Resilience Mapping reference.

Problem framing

When a distributed system returns an error, the client faces a binary decision: retry the request or surface a failure to the caller. Getting this wrong in either direction causes real harm. Blindly retrying a 403 Forbidden burns quota and can trigger rate-limit lockouts. Failing fast on a 503 Service Unavailable that would have resolved in two seconds destroys availability unnecessarily. The root problem is that HTTP status codes alone do not carry retry intent — a 400 from a misconfigured client looks identical to a 400 from a bug the server will never recover from. Explicit error classification in the API contract — agreed upon at design time and enforced in CI — is the only way to give clients deterministic, machine-readable guidance without brittle string parsing.

This page shows how to embed retry classification in your OpenAPI specification, implement server-side Retry-After headers, write client-side interceptors for TypeScript, Python, and Go, and lock the policy in with Spectral lint rules and contract tests.

Decision diagram: retryable vs terminal error flow

API Error Classification Decision Flow A flowchart showing how a client classifies an HTTP error response as retryable or terminal, checks idempotency, reads Retry-After, and either backs off or fails fast. API error response x-retryable = true? yes no Fail fast — surface error Check Idempotency- Key header present? Read Retry-After header (or default) Jitter + exponential backoff, then retry retry loop Return error Max retries exceeded → circuit open → fail fast

Spec definition

Embed retry classification directly in your OpenAPI 3.1 responses using the x-retryable vendor extension. This makes intent machine-readable for code generators, lint rules, and API gateways.

paths:
  /v1/transactions:
    post:
      operationId: createTransaction
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '201':
          description: Transaction created
        '429':
          description: Rate limit exceeded
          x-retryable: true
          headers:
            Retry-After:
              required: true
              schema:
                type: integer
                description: Seconds until the client may retry
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
        '503':
          description: Service temporarily unavailable
          x-retryable: true
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
        '400':
          description: Invalid request payload — do not retry
          x-retryable: false
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
        '401':
          description: Missing or invalid credentials — do not retry
          x-retryable: false
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

components:
  schemas:
    ProblemDetails:
      type: object
      required: [type, title, status]
      properties:
        type:
          type: string
          format: uri
          description: URI identifying the error category
        title:
          type: string
        status:
          type: integer
        detail:
          type: string
        instance:
          type: string
          format: uri
        trace_id:
          type: string
          format: uuid
        retryable:
          type: boolean
          description: Whether the client may safely retry this request
        backoff_strategy:
          type: string
          enum: [exponential, linear, fixed, none]
        retry_after_ms:
          type: integer
          minimum: 0
          description: Minimum milliseconds before the next retry attempt

The ProblemDetails schema aligns with RFC 7807 Problem+JSON and adds three fields that drive automated client behaviour: retryable, backoff_strategy, and retry_after_ms.

RFC / standard alignment

Classification Status Codes RFC / Spec reference Retry policy
Transient — auto-retry 429, 502, 503, 504 RFC 7231 §6.6, RFC 6585 §4 Jittered exponential backoff; honour Retry-After
Transient — conditional 408 RFC 7231 §6.5.7 Retry with backoff; check idempotency
State conflict — manual 409 RFC 7231 §6.5.8 Reconcile state before retry
Terminal — client error 400, 401, 403, 404, 422 RFC 7231 §6.5 Fail fast; surface to caller
Terminal — not implemented 501 RFC 7231 §6.6.2 Fail fast; indicates a code defect

408 Request Timeout and 409 Conflict deserve explicit treatment. A 408 is structurally retryable — the server abandoned the connection, not the semantic request — but you must verify idempotency before doing so. A 409 signals a resource state mismatch; the correct response is to fetch the current state, resolve the conflict, and then resubmit, not to blindly replay the same request.

Implementation walkthrough — server side

The server’s responsibility is to return the Retry-After header on every retryable response, populate the Problem+JSON body correctly, and reject duplicate requests when an Idempotency-Key has already been processed.

// Express.js — retryable error middleware
import { Request, Response, NextFunction } from 'express';

interface ProblemDetails {
  type: string;
  title: string;
  status: number;
  detail?: string;
  instance?: string;
  trace_id: string;
  retryable: boolean;
  backoff_strategy: 'exponential' | 'linear' | 'fixed' | 'none';
  retry_after_ms?: number;
}

// Map status code → retry metadata
const RETRY_POLICY: Record<number, { retryable: boolean; backoffMs?: number; strategy: ProblemDetails['backoff_strategy'] }> = {
  429: { retryable: true,  backoffMs: 1000, strategy: 'exponential' },
  502: { retryable: true,  backoffMs: 500,  strategy: 'exponential' },
  503: { retryable: true,  backoffMs: 1000, strategy: 'exponential' },
  504: { retryable: true,  backoffMs: 2000, strategy: 'exponential' },
  408: { retryable: true,  backoffMs: 500,  strategy: 'linear' },
  400: { retryable: false, strategy: 'none' },
  401: { retryable: false, strategy: 'none' },
  403: { retryable: false, strategy: 'none' },
  404: { retryable: false, strategy: 'none' },
  409: { retryable: false, strategy: 'none' }, // state conflict — not auto-retryable
  422: { retryable: false, strategy: 'none' },
};

export function sendProblem(
  res: Response,
  status: number,
  title: string,
  detail?: string,
  traceId?: string
): void {
  const policy = RETRY_POLICY[status] ?? { retryable: false, strategy: 'none' };

  if (policy.retryable && policy.backoffMs) {
    res.setHeader('Retry-After', Math.ceil(policy.backoffMs / 1000));
  }

  const body: ProblemDetails = {
    type: `https://api-contract.com/errors/${status}`,
    title,
    status,
    detail,
    instance: res.req?.originalUrl,
    trace_id: traceId ?? crypto.randomUUID(),
    retryable: policy.retryable,
    backoff_strategy: policy.strategy,
    retry_after_ms: policy.backoffMs,
  };

  res
    .status(status)
    .contentType('application/problem+json')
    .json(body);
}

The RETRY_POLICY map is the single source of truth on the server side. Every HTTP error goes through sendProblem, which sets Retry-After and serialises the body consistently. When you add a new error code, updating the map is all that is needed — the response shape stays stable.

Implementation walkthrough — client side

Clients should read x-retryable (or the response body’s retryable field) and the Retry-After header rather than hard-coding status codes. Here are production-quality interceptors in three languages.

TypeScript (Axios)

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

const RETRYABLE_STATUSES = new Set([408, 429, 502, 503, 504]);
const MAX_RETRIES = 3;

function isRetryable(error: AxiosError): boolean {
  const status = error.response?.status ?? 0;
  const headerFlag = error.response?.headers['x-retryable'];
  const bodyFlag = (error.response?.data as any)?.retryable;

  // Explicit contract header wins; fall back to body flag, then status heuristic
  if (headerFlag !== undefined) return headerFlag === 'true';
  if (bodyFlag !== undefined) return Boolean(bodyFlag);
  return RETRYABLE_STATUSES.has(status);
}

function retryAfterMs(error: AxiosError, attempt: number): number {
  const header = error.response?.headers['retry-after'];
  const bodyMs = (error.response?.data as any)?.retry_after_ms as number | undefined;
  const base = bodyMs ?? (header ? parseInt(header, 10) * 1000 : 1000);
  const jitter = Math.random() * 200;
  return Math.min(base * Math.pow(2, attempt) + jitter, 30_000);
}

export function attachRetryInterceptor(client: AxiosInstance): void {
  client.interceptors.response.use(undefined, async (error: AxiosError) => {
    const config = error.config as AxiosRequestConfig & { _retryCount?: number };
    config._retryCount = config._retryCount ?? 0;

    if (!isRetryable(error) || config._retryCount >= MAX_RETRIES) throw error;

    const delay = retryAfterMs(error, config._retryCount);
    config._retryCount += 1;

    await new Promise(resolve => setTimeout(resolve, delay));
    return client(config);
  });
}

Python (httpx)

import httpx
import asyncio
import random
import math
from typing import Set

RETRYABLE_STATUSES: Set[int] = {408, 429, 502, 503, 504}
MAX_RETRIES = 3

def _is_retryable(response: httpx.Response) -> bool:
    header_flag = response.headers.get("x-retryable")
    if header_flag is not None:
        return header_flag.lower() == "true"
    try:
        body_flag = response.json().get("retryable")
        if body_flag is not None:
            return bool(body_flag)
    except Exception:
        pass
    return response.status_code in RETRYABLE_STATUSES

def _retry_delay_ms(response: httpx.Response, attempt: int) -> float:
    try:
        body_ms = response.json().get("retry_after_ms")
        if body_ms:
            base = int(body_ms)
        else:
            raise ValueError
    except (ValueError, AttributeError):
        retry_after = response.headers.get("retry-after", "1")
        base = int(retry_after) * 1000

    jitter = random.uniform(0, 200)
    return min(base * math.pow(2, attempt) + jitter, 30_000)

class RetryTransport(httpx.AsyncBaseTransport):
    def __init__(self, wrapped: httpx.AsyncBaseTransport, max_retries: int = MAX_RETRIES):
        self._wrapped = wrapped
        self._max_retries = max_retries

    async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
        for attempt in range(self._max_retries + 1):
            response = await self._wrapped.handle_async_request(request)
            if attempt < self._max_retries and _is_retryable(response):
                delay_ms = _retry_delay_ms(response, attempt)
                await asyncio.sleep(delay_ms / 1000)
                continue
            return response
        return response  # return last response; caller inspects status

Go (http.RoundTripper)

package transport

import (
    "encoding/json"
    "math"
    "math/rand"
    "net/http"
    "strconv"
    "time"
)

var retryableStatuses = map[int]bool{408: true, 429: true, 502: true, 503: true, 504: true}

type RetryTransport struct {
    Base       http.RoundTripper
    MaxRetries int
}

type problemBody struct {
    Retryable    bool   `json:"retryable"`
    RetryAfterMs int    `json:"retry_after_ms"`
}

func (t *RetryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    for attempt := 0; attempt <= t.MaxRetries; attempt++ {
        resp, err := t.Base.RoundTrip(req)
        if err != nil || !t.shouldRetry(resp, attempt) {
            return resp, err
        }
        delay := t.retryDelay(resp, attempt)
        resp.Body.Close()
        time.Sleep(delay)
    }
    return t.Base.RoundTrip(req)
}

func (t *RetryTransport) shouldRetry(resp *http.Response, attempt int) bool {
    if attempt >= t.MaxRetries {
        return false
    }
    if h := resp.Header.Get("X-Retryable"); h != "" {
        ok, _ := strconv.ParseBool(h)
        return ok
    }
    var pb problemBody
    if err := json.NewDecoder(resp.Body).Decode(&pb); err == nil {
        return pb.Retryable
    }
    return retryableStatuses[resp.StatusCode]
}

func (t *RetryTransport) retryDelay(resp *http.Response, attempt int) time.Duration {
    var pb problemBody
    json.NewDecoder(resp.Body).Decode(&pb) // best-effort
    baseMs := float64(pb.RetryAfterMs)
    if baseMs == 0 {
        if ra := resp.Header.Get("Retry-After"); ra != "" {
            secs, _ := strconv.Atoi(ra)
            baseMs = float64(secs * 1000)
        }
        if baseMs == 0 {
            baseMs = 1000
        }
    }
    jitter := rand.Float64() * 200
    delay := math.Min(baseMs*math.Pow(2, float64(attempt))+jitter, 30_000)
    return time.Duration(delay) * time.Millisecond
}

All three implementations share the same priority order for determining retry intent: explicit x-retryable header → retryable field in the Problem+JSON body → status-code heuristic. This means clients degrade gracefully against services that have not yet adopted the vendor extension.

Edge-case handling

Non-idempotent mutations and idempotency keys. Retrying a POST without an idempotency key can create duplicate resources. Declare Idempotency-Key as a required header on every retryable mutation endpoint. Connect this with the Idempotency Key Implementation pattern: the server stores the key and returns the cached response for any replay within the deduplication window. The client must generate a new key only when the operation is intentionally resubmitted, not on automatic retries.

Bulk operations. A 207 Multi-Status response may contain a mix of succeeded and failed sub-operations. Parse the individual status fields in the response body rather than applying a single retry decision at the HTTP envelope level. Retry only the failed sub-operations that are individually retryable.

State conflict under concurrent writes. A 409 Conflict is not automatically retryable, but it is often recoverable. The client must fetch the current resource state (GET), merge or discard its changes, and then resubmit. SDKs should expose 409 as a distinct ConflictError type — not a subclass of retryable errors — so calling code handles reconciliation explicitly.

Circuit breaking. Infinite retry loops with backoff are not enough during sustained outages. After a configurable threshold of consecutive failures, open the circuit and fail fast for a cool-down period. This prevents thundering-herd effects when the server starts to recover. The Client Fallback Strategies page covers circuit-breaker wiring in detail.

Rate-limit window alignment. When a 429 arrives, the Retry-After header tells the client when the quota resets. Do not randomise jitter across this boundary — wait until after the reset point before sending any requests, then apply jitter to spread load among concurrent clients.

Validation and testing patterns

Lock the classification contract in CI with a Spectral rule that fails the build when any 5xx response is missing x-retryable.

# .spectral.yaml
rules:
  require-retryable-on-5xx:
    description: Every 5xx response must declare x-retryable (true or false)
    severity: error
    given: "$.paths[*][*].responses[?(@property >= '500' && @property <= '599')]"
    then:
      field: x-retryable
      function: defined

  require-retry-after-header-on-retryable:
    description: Retryable responses must declare a Retry-After header
    severity: warn
    given: "$.paths[*][*].responses[?(@['x-retryable'] == true)]"
    then:
      field: headers.Retry-After
      function: defined

Run it in the same step as your existing schema validation:

npm install -g @stoplight/spectral-cli
spectral lint openapi.yaml --ruleset .spectral.yaml --fail-severity error

For integration tests, use schemathesis to drive the server against its own spec and confirm that simulated 503 responses include both the Retry-After header and a conformant Problem+JSON body:

pip install schemathesis
schemathesis run openapi.yaml \
  --base-url https://api.staging.internal \
  --checks all \
  --validate-schema \
  --hypothesis-phases explicit

A focused contract test against the retry path:

# tests/test_retry_contract.py
import httpx
import pytest

BASE = "https://api.staging.internal"

@pytest.mark.asyncio
async def test_503_is_retryable_contract():
    """Server must return Retry-After header and retryable=true body on 503."""
    async with httpx.AsyncClient() as client:
        # Trigger a synthetic 503 via the chaos header (staging only)
        resp = await client.post(
            f"{BASE}/v1/transactions",
            headers={"X-Chaos-Status": "503", "Idempotency-Key": "test-key-001"},
            json={"amount": 100}
        )
    assert resp.status_code == 503
    assert resp.headers.get("retry-after") is not None, "Retry-After header missing"
    body = resp.json()
    assert body.get("retryable") is True, "Problem+JSON body missing retryable: true"
    assert body.get("retry_after_ms") is not None, "retry_after_ms missing from body"

SDK generation impact

The x-retryable extension and the ProblemDetails schema work together to generate typed, policy-aware error classes. Add a custom Mustache template that maps x-retryable: true to a RetryableApiError class and x-retryable: false to a TerminalApiError class:

// Generated output (TypeScript — typescript-axios generator)
export class RetryableApiError extends ApiError {
  readonly retryable = true as const;
  readonly retryAfterMs: number;
  readonly backoffStrategy: 'exponential' | 'linear' | 'fixed';

  constructor(response: ProblemDetails, retryAfterMs: number) {
    super(response);
    this.retryAfterMs = retryAfterMs;
    this.backoffStrategy = response.backoff_strategy ?? 'exponential';
  }
}

export class TerminalApiError extends ApiError {
  readonly retryable = false as const;
}

This type structure means the TypeScript compiler enforces correct handling at call sites:

try {
  await client.createTransaction({ amount: 100 });
} catch (err) {
  if (err instanceof RetryableApiError) {
    // TypeScript knows err.retryAfterMs exists here
    await queue.scheduleRetry(err.retryAfterMs);
  } else if (err instanceof TerminalApiError) {
    logger.error('non-retryable failure', { err });
    throw err; // do not retry
  }
}

The Python generator should produce RetryableApiError(ApiError) and TerminalApiError(ApiError) dataclasses with the same fields. The Go generator maps to type RetryableError struct and type TerminalError struct, each implementing a common APIError interface. See Configuring Exponential Backoff for 5xx Errors for full codegen template examples.

Anti-patterns quick-reference

Anti-pattern Correct approach
Retrying all 4xx responses Only 408 and conditionally 409 are retryable; 400, 401, 403, 404, 422 must fail fast
Fixed-delay retry loops Use jittered exponential backoff seeded from the Retry-After value
Retrying POST without idempotency keys Require Idempotency-Key on all retryable mutations; generate a stable key before the first attempt
Ignoring X-Retryable header on 2xx gateway caches A cached 2xx from a proxy may mask a real 503 — validate freshness with Cache-Control headers
Hardcoding status codes in client code Read x-retryable from the response; let the spec drive retry behaviour
No circuit breaker Cap total retry time and open the circuit after N consecutive failures; see Client Fallback Strategies
Generating untyped error unions in SDKs Map x-retryable to typed error classes so the compiler enforces handling at call sites

FAQ

Should 408 Request Timeout and 409 Conflict be retried automatically?

408 is safely retried with jittered backoff — the server dropped the connection before reading the body, so no side effects occurred (assuming idempotency). 409 requires conflict resolution before retry: fetch the current state, merge your changes, then resubmit. Expose these as distinct error types in generated clients so callers cannot accidentally conflate them.

How do I enforce retryable classification in CI/CD?

Use the Spectral rule shown above to require x-retryable on every 5xx response object. Add a second rule that warns when a retryable response omits the Retry-After header declaration. Fail the pipeline on any error-severity finding before the SDK generation step runs.

How do I avoid duplicate resource creation when retrying POST requests?

Require an Idempotency-Key header on every retryable POST and declare it in the spec as required: true. The server stores the key with its outcome and returns the cached response on replay. Generate the key before the first attempt and reuse it across all retries within the same logical operation.

How should generated SDK clients surface retryable vs terminal errors?

Map x-retryable: true to a RetryableApiError class and x-retryable: false to a TerminalApiError class during codegen. Both extend a common ApiError base. This gives consuming code compile-time certainty — a catch (err) block can use instanceof to branch without inspecting status codes at runtime.