Error Contracts & Resilience Mapping
In distributed API systems, unstructured error responses are one of the most expensive forms of technical debt: clients hard-code string matching, monitoring alerts fire on every 500, and retry logic duplicates across every service boundary. This reference establishes contract-first error design as a first-class architectural surface — covering schema definition, failure classification, CI enforcement, and SDK-level resilience automation — for backend engineers and platform teams who need deterministic failure behavior at scale.
Architecture Overview
A contract-first error strategy treats error payloads as versioned, schema-validated artifacts that encode actionable metadata for both human operators and automated middleware. The four areas this reference covers map directly to the four sections below:
- RFC 7807 Problem+JSON Implementation — canonical schema definition anchored to the
application/problem+jsonmedia type (RFC 9457 supersedes RFC 7807 while preserving the same five core fields) - HTTP Status Code Mapping — deterministic alignment between HTTP transport codes and domain failure categories
- Retryable vs Non-Retryable Errors — recoverability taxonomy that drives middleware routing, circuit breakers, and exponential back-off
- Client Fallback Strategies — SDK interceptor patterns, graceful degradation, and cache fallback execution driven by contract metadata
The flow from spec definition to client resilience looks like this:
Spec and Schema Definition
A robust error contract begins with a machine-readable baseline that every service, gateway, and generated SDK can validate against. The canonical schema below anchors the RFC 7807 Problem+JSON Implementation to an OpenAPI 3.1 component, making it reusable across every endpoint via $ref. The x-error-classification vendor extension is the automation hook that drives SDK generation and gateway routing without body inspection.
openapi: 3.1.0
info:
title: Resilient Service API
version: 2.1.0
paths:
/orders:
post:
operationId: createOrder
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateOrderRequest'
responses:
'201':
description: Order created
'400':
description: Validation failure
x-error-classification: permanent
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ProblemDetail'
'409':
description: Resource conflict
x-error-classification: business
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ConflictProblem'
'429':
description: Rate limit exceeded
x-error-classification: transient
headers:
Retry-After:
schema:
type: integer
description: Seconds until the rate limit window resets
content:
application/problem+json:
schema:
$ref: '#/components/schemas/RateLimitProblem'
'500':
description: Internal server error
x-error-classification: transient
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ProblemDetail'
components:
schemas:
ProblemDetail:
# Canonical RFC 9457 problem object (supersedes RFC 7807, same media type)
type: object
required: [type, title, status]
additionalProperties: false
properties:
type:
type: string
format: uri
description: >
URI reference that identifies the error class. Clients dereference
this URI to find human-readable documentation. Use a stable,
versioned namespace (e.g. https://errors.example.com/v1/rate-limit).
title:
type: string
description: Short, human-readable summary that does not change between occurrences.
status:
type: integer
minimum: 100
maximum: 599
description: HTTP status code, mirroring the transport-layer code.
detail:
type: string
description: Explanation of this specific occurrence; safe to display to developers.
instance:
type: string
format: uri
description: URI identifying this specific request instance for log correlation.
trace_id:
type: string
format: uuid
description: Distributed trace correlation ID; maps directly to OpenTelemetry span.
retry_after:
type: integer
description: Seconds a client should wait before retrying (transient errors only).
RateLimitProblem:
allOf:
- $ref: '#/components/schemas/ProblemDetail'
- type: object
properties:
limit:
type: integer
description: Maximum requests permitted in the current window.
remaining:
type: integer
description: Requests remaining in the current window.
reset_at:
type: string
format: date-time
ConflictProblem:
allOf:
- $ref: '#/components/schemas/ProblemDetail'
- type: object
properties:
conflicting_resource:
type: string
format: uri
description: URI of the resource causing the conflict.
Field-by-field rationale:
type— a dereferenceable URI anchors the error to stable documentation. Do not use generic strings like"error"or"bad_request".title— a static label for the error class, not the occurrence. Clients use this for exception class names in generated SDKs.status— mirrors the HTTP status code so clients that parse the body do not need to inspect transport headers separately.detail— occurrence-specific explanation, safe for developer logs. Do not expose internal stack traces here.instance— per-request identifier that correlates with your distributed tracingtrace_id.x-error-classification— vendor extension (not an RFC field) that signals retry intent to code generators and gateways without body inspection.
Core Pattern 1 — HTTP Status Code Alignment
Predictable client routing requires strict alignment between HTTP status codes and error categories. Misaligned codes break API gateway routing rules, load balancer health checks, and client-side exception dispatchers. The HTTP Status Code Mapping section establishes deterministic mappings that separate infrastructure failures from business-logic constraints.
| HTTP Code | Classification | Client Action | Gateway Behavior |
|---|---|---|---|
| 400 | Permanent | Fail fast, fix the request | Drop; return cached default if available |
| 401 | Permanent | Refresh token, then retry once | Drop; redirect to auth |
| 403 | Permanent | Fail fast; surface to user | Drop; no retry |
| 404 | Permanent | Fail fast; check resource URI | Drop |
| 409 | Business | Surface conflict to UI; halt workflow | Route to conflict handler |
| 422 | Business | Surface validation detail to UI | Drop |
| 429 | Transient | Exponential backoff + jitter | Throttle; shed load |
| 500 | Transient | Retry with back-off | Circuit open → half-open |
| 502 | Transient | Retry; upstream is down | Circuit open |
| 503 | Transient | Retry after Retry-After header |
Shed load; activate fallback pool |
| 504 | Transient | Retry; upstream timed out | Circuit open |
Embedding retry_after or idempotency_key fields in transient payloads allows clients to resume operations safely without duplicating side effects — a critical concern when your operations use Idempotency Key Implementation to guard non-idempotent POST endpoints.
// TypeScript — parse retry_after from a transient problem payload
import axios, { AxiosError } from 'axios';
interface ProblemDetail {
type: string;
title: string;
status: number;
detail?: string;
classification?: 'transient' | 'permanent' | 'business';
retry_after?: number;
trace_id?: string;
}
export class TransientError extends Error {
constructor(
public readonly problem: ProblemDetail,
public readonly retryAfterMs: number,
) {
super(problem.title);
this.name = 'TransientError';
}
}
export class PermanentError extends Error {
constructor(public readonly problem: ProblemDetail) {
super(problem.title);
this.name = 'PermanentError';
}
}
export class BusinessError extends Error {
constructor(public readonly problem: ProblemDetail) {
super(problem.title);
this.name = 'BusinessError';
}
}
const resilientClient = axios.create({
headers: { Accept: 'application/problem+json, application/json' },
});
resilientClient.interceptors.response.use(
(res) => res,
(error: AxiosError<ProblemDetail>) => {
const problem = error.response?.data;
const retryAfterHeader = error.response?.headers['retry-after'];
const retryMs = (problem?.retry_after ?? Number(retryAfterHeader) ?? 60) * 1000;
switch (problem?.classification) {
case 'transient':
throw new TransientError(problem, retryMs);
case 'business':
throw new BusinessError(problem ?? { type: 'unknown', title: 'Business Error', status: 0 });
default:
throw new PermanentError(problem ?? { type: 'unknown', title: 'Unknown Error', status: 0 });
}
},
);
Core Pattern 2 — Retryable vs Non-Retryable Classification
Error contracts must encode actionable metadata to drive automated recovery workflows. The Retryable vs Non-Retryable Errors taxonomy dictates when clients should back off, when they should fail fast, and when they should escalate to alternative execution paths. Classifying at the spec level — via x-error-classification — means middleware can make routing decisions without inspecting the payload body, which is essential for high-throughput gateways.
# Python — exponential backoff with jitter for transient errors
import time
import random
import requests
from dataclasses import dataclass
from typing import Optional
@dataclass
class ProblemDetail:
type: str
title: str
status: int
detail: Optional[str] = None
classification: Optional[str] = None
retry_after: Optional[int] = None
trace_id: Optional[str] = None
class TransientError(Exception):
def __init__(self, problem: ProblemDetail):
super().__init__(problem.title)
self.problem = problem
class PermanentError(Exception):
def __init__(self, problem: ProblemDetail):
super().__init__(problem.title)
self.problem = problem
def parse_problem(response: requests.Response) -> ProblemDetail:
data = response.json()
return ProblemDetail(**{k: data.get(k) for k in ProblemDetail.__dataclass_fields__})
def request_with_backoff(
method: str,
url: str,
max_retries: int = 4,
base_delay: float = 1.0,
**kwargs,
) -> requests.Response:
for attempt in range(max_retries + 1):
resp = requests.request(method, url, **kwargs)
if resp.ok:
return resp
problem = parse_problem(resp)
if problem.classification != 'transient':
raise PermanentError(problem)
if attempt == max_retries:
raise TransientError(problem)
# Full jitter: sleep between 0 and base_delay * 2^attempt seconds
delay = problem.retry_after or (base_delay * (2 ** attempt))
jitter = random.uniform(0, delay)
time.sleep(jitter)
raise RuntimeError("Unreachable")
Core Pattern 3 — Client Fallback Strategies
Generated SDKs and full-stack consumers must translate contract signals into runtime resilience patterns. Rather than scattering try/catch blocks across business logic, platform teams should centralise error handling in interceptors, middleware, or generated exception hierarchies. The Client Fallback Strategies guide covers circuit breakers, graceful degradation, and cache fallbacks driven by explicit contract metadata.
A circuit breaker driven by the x-error-classification field transitions through three states:
- Closed — requests pass through normally; transient errors increment a failure counter.
- Open — after the failure threshold, the breaker rejects requests immediately and returns a cached or default response.
- Half-open — after a recovery timeout, one probe request tests the upstream; success closes the breaker, failure reopens it.
// TypeScript — circuit breaker wrapping the resilient Axios client
type BreakerState = 'closed' | 'open' | 'half-open';
class CircuitBreaker {
private state: BreakerState = 'closed';
private failures = 0;
private lastFailureTime = 0;
constructor(
private readonly failureThreshold: number = 5,
private readonly recoveryTimeoutMs: number = 30_000,
) {}
async execute<T>(fn: () => Promise<T>, fallback: () => T): Promise<T> {
if (this.state === 'open') {
if (Date.now() - this.lastFailureTime > this.recoveryTimeoutMs) {
this.state = 'half-open';
} else {
return fallback();
}
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (err) {
if (err instanceof TransientError) {
this.onFailure();
}
throw err;
}
}
private onSuccess(): void {
this.failures = 0;
this.state = 'closed';
}
private onFailure(): void {
this.failures++;
this.lastFailureTime = Date.now();
if (this.failures >= this.failureThreshold) {
this.state = 'open';
}
}
}
// Usage: wrap any API call that returns a ProblemDetail on failure
const breaker = new CircuitBreaker(5, 30_000);
async function getOrderWithFallback(orderId: string) {
return breaker.execute(
() => resilientClient.get(`/orders/${orderId}`).then((r) => r.data),
() => ({ id: orderId, status: 'unknown', cached: true }),
);
}
CI/CD Enforcement
Error contracts degrade silently without continuous validation. Use Spectral custom rules to reject any OpenAPI response that lacks the application/problem+json media type, and run Schemathesis property-based tests on every pull request to verify that live error payloads match the schema.
# .spectral.yaml — enforce problem+json on all 4xx/5xx responses
rules:
error-responses-must-use-problem-json:
severity: error
given: "$.paths[*][*].responses[?(@property >= '400' && @property <= '599')]"
then:
- field: content.application/problem+json
function: truthy
message: "Error responses must use content-type application/problem+json"
error-responses-must-reference-problem-detail:
severity: error
given: "$.paths[*][*].responses[?(@property >= '400')].content['application/problem+json'].schema"
then:
- field: "$ref"
function: pattern
functionOptions:
match: "ProblemDetail$|Problem$"
message: "Error schema must $ref a ProblemDetail component"
transient-errors-must-declare-retry-after:
severity: warn
given: "$.paths[*][*].responses[?(@property == '429' || @property == '503')]"
then:
- field: headers.Retry-After
function: truthy
message: "429 and 503 responses should declare a Retry-After header"
# .github/workflows/contract-check.yml
name: Validate Error Contracts
on: [pull_request]
jobs:
spectral-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Spectral
run: npm install -g @stoplight/spectral-cli
- name: Lint OpenAPI spec
run: spectral lint ./openapi.yaml --ruleset ./.spectral.yaml --fail-severity error
schemathesis:
runs-on: ubuntu-latest
needs: spectral-lint
steps:
- uses: actions/checkout@v4
- name: Install Schemathesis
run: pip install schemathesis
- name: Start service (replace with your stack)
run: docker compose up -d api && sleep 5
- name: Run error payload assertions
run: |
schemathesis run ./openapi.yaml \
--url http://localhost:8080 \
--checks all \
--validate-schema \
--header "Accept: application/problem+json" \
--hypothesis-phases explicit,generate \
--stateful=links
drift-detection:
runs-on: ubuntu-latest
needs: schemathesis
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Detect breaking changes in error schemas
run: |
npm install -g @openapitools/openapi-diff
npx openapi-diff \
HEAD~1:openapi.yaml \
HEAD:openapi.yaml \
--fail-on-incompatible
SDK and Client Impact
The x-error-classification vendor extension and the ProblemDetail component shape directly affect generated client code. When you configure openapi-generator with errorHandling=strict, the generator emits a typed exception hierarchy matched to each classification value:
openapi-generator-cli generate \
-i openapi.yaml \
-g typescript-axios \
--additional-properties=errorHandling=strict,useSingleRequestParameter=true,withSeparateModelsAndApi=true \
-o ./generated-sdk
The generated output includes:
// generated-sdk/errors.ts (illustrative — actual output varies by generator version)
export class TransientApiError extends ApiError {
readonly retryAfterMs: number;
constructor(problem: ProblemDetail, retryAfterMs: number) {
super(problem);
this.retryAfterMs = retryAfterMs;
this.name = 'TransientApiError';
}
}
export class PermanentApiError extends ApiError {
constructor(problem: ProblemDetail) {
super(problem);
this.name = 'PermanentApiError';
}
}
export class BusinessApiError extends ApiError {
constructor(problem: ProblemDetail) {
super(problem);
this.name = 'BusinessApiError';
}
}
For Go consumers, the same classification feeds a ShouldRetry function generated from the vendor extension:
// RetryPolicy reads x-error-classification from the parsed response body.
type RetryPolicy struct {
MaxRetries int
BaseDelay time.Duration
}
func (rp *RetryPolicy) ShouldRetry(resp *http.Response) bool {
if resp == nil {
return true
}
switch resp.StatusCode {
case http.StatusTooManyRequests,
http.StatusBadGateway,
http.StatusServiceUnavailable,
http.StatusGatewayTimeout:
return true
}
return resp.StatusCode >= 500
}
func (rp *RetryPolicy) BackoffDelay(attempt int, retryAfter time.Duration) time.Duration {
if retryAfter > 0 {
return retryAfter
}
delay := rp.BaseDelay * (1 << attempt)
jitter := time.Duration(rand.Int63n(int64(delay)))
return delay + jitter
}
Independent versioning of the error schema (via info.x-error-schema-version) prevents breaking client exception handlers during minor endpoint changes — a pattern that pairs with your broader API Design Fundamentals & Architecture versioning strategy.
Edge Cases and Anti-Patterns
| Anti-pattern | Recommended approach |
|---|---|
Generic 500 for business validation failures |
Return 422 Unprocessable Entity with a typed ProblemDetail that names the failing field; 500 should mean unexpected infrastructure failure only |
| Inconsistent error schemas across microservices | Define ProblemDetail once in a shared OpenAPI component library; each service $refs it rather than redefining inline |
Transient errors without retry_after or idempotency_key |
Embed retry_after in the payload and require callers to include an idempotency key header so retries are safe to replay |
| Coupling HTTP codes directly to domain exception names in code | Map transport codes to classification values in a single interceptor or middleware; business logic should never branch on HTTP codes |
| Tying error schema versioning to endpoint major versions | Maintain a separate x-error-schema-version field and follow semantic versioning for the error component independently of endpoint paths |
Returning 404 for authorization failures to hide resource existence |
Use 403 for authenticated requests denied by policy; only use 404 when the resource genuinely does not exist |
Exposing internal stack traces in detail |
The detail field is developer-facing, not end-user-facing, but must still be sanitised — log the full trace behind trace_id, never in the payload |
FAQ
How do error contracts affect automated client SDK generation?
Structured error schemas let code generators emit dedicated exception classes, typed retry policies, and fallback hooks directly from OpenAPI definitions. The x-error-classification extension maps to concrete class hierarchies (TransientError, PermanentError, BusinessError), eliminating hand-written boilerplate and preventing runtime parsing failures during contract updates.
Should error schemas be versioned independently of API endpoints?
Yes. Error schemas should follow semantic versioning to prevent breaking client exception handlers during minor API updates. Tie a x-error-schema-version field to the ProblemDetail component and update it independently of endpoint paths. This lets platform teams evolve failure semantics — for example, adding a validation_errors extension member — without forcing endpoint deprecations or client rewrites.
What is the difference between transient and permanent error classifications?
Transient errors (429, 502, 503, 504) indicate temporary infrastructure conditions where exponential backoff and retry are appropriate. Permanent errors (400, 401, 403, 404, 410) indicate that retrying without changing the request will never succeed. Clients should fail fast on permanent codes and surface the problem to the caller rather than consuming retry budget unnecessarily.
How do I enforce error contract compliance in CI without slowing down the pipeline?
Run Spectral lint rules and Schemathesis property-based tests in parallel on every pull request. Spectral validates schema structure in under two seconds; Schemathesis runs targeted hypothesis tests against a local server. Gate the merge on spectral-lint passing synchronously and treat schemathesis as a required status check. Reserve the drift-detection step for nightly builds against the staging environment.
Can I extend RFC 7807 Problem+JSON with domain-specific fields?
Yes. RFC 9457 (which supersedes RFC 7807, keeping the same application/problem+json media type) explicitly permits extension members. Add domain fields such as trace_id, retry_after, or validation_errors as top-level properties in the problem object. Document every extension field in your OpenAPI schema with additionalProperties: false to prevent undeclared fields from appearing in production payloads undetected.
Related
- RFC 7807 Problem+JSON Implementation — canonical schema definition, extension members, and type URI conventions
- HTTP Status Code Mapping — deterministic code-to-classification alignment for gateways and load balancers
- Retryable vs Non-Retryable Errors — recoverability taxonomy and exponential backoff configuration
- Client Fallback Strategies — circuit breakers, graceful degradation, and cache fallback patterns
- API Design Fundamentals & Architecture — companion reference for resource modeling, idempotency, and statelessness design decisions