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:

The flow from spec definition to client resilience looks like this:

Error Contract Architecture Flow Diagram showing how an OpenAPI spec anchors the ProblemDetail schema, CI gates enforce compliance, error classification drives gateway and middleware routing decisions, and client SDKs apply the correct resilience pattern (retry, fallback, or fail-fast). OpenAPI 3.1 Spec ProblemDetail schema CI Enforcement Spectral + Schemathesis Error Classification Transient 429, 502, 503, 504 → retry Permanent 400, 401, 403, 404 → fail fast Business 409, 422, 451 → surface to UI Client Resilience Exponential backoff + jitter Circuit breaker open / half-open Cache fallback + graceful degrade OpenAPI spec → classification → automated resilience

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:

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:

  1. Closed — requests pass through normally; transient errors increment a failure counter.
  2. Open — after the failure threshold, the breaker rejects requests immediately and returns a cached or default response.
  3. 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.