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
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.
Related
- Error Contracts & Resilience Mapping — parent reference for all error-handling patterns
- Configuring Exponential Backoff for 5xx Errors — deep-dive on backoff algorithms and codegen templates
- RFC 7807 Problem+JSON Implementation — standard error payload shape that retry-aware responses extend
- HTTP Status Code Mapping — canonical status-code semantics for contract authors
- Client Fallback Strategies — circuit breaking and fallback patterns that complement retry logic
- Idempotency Key Implementation — how to guard non-idempotent mutations before retrying