HTTP Status Code Mapping & Client Generation

Part of the Error Contracts & Resilience Mapping reference. Deterministic HTTP status code mapping is the foundation of predictable API behavior, automated resilience, and type-safe client ecosystems. When status codes are implicit or undocumented, clients resort to brittle try/catch chains, SDK generators fall back to any types, and on-call engineers spend hours distinguishing transient faults from permanent failures at 3 a.m.

Problem Framing

The root failure is treating HTTP status codes as an implementation detail rather than a contract surface. A service that returns 500 for validation failures causes clients to retry permanently bad requests, exhausting rate limits and polluting SLO error budgets. A service that omits Retry-After on 429 invites retry storms that degrade the very upstream it depends on. And a service whose OpenAPI spec lists only 200 responses forces every client team to reverse-engineer error semantics from runtime observation. This page covers the full contract-first workflow: spec definition, RFC alignment, server and client implementation, CI enforcement, and SDK generation impact.

Spec Definition

Define a unified error schema using $ref to guarantee payload consistency across all endpoints. Attach application/problem+json as the canonical content type to align with RFC 7807 Problem+JSON Implementation conventions.

# openapi.yaml — OpenAPI 3.1.0
paths:
  /v1/orders:
    post:
      summary: Create order
      responses:
        '201':
          description: Order created successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
        '400':
          $ref: '#/components/responses/ValidationError'
        '409':
          $ref: '#/components/responses/ConflictError'
        '429':
          $ref: '#/components/responses/RateLimitError'
        '500':
          $ref: '#/components/responses/InternalServerError'

components:
  schemas:
    ProblemDetails:
      type: object
      required: [type, title, status]
      properties:
        type:
          type: string
          format: uri
          description: URI identifying the error class (dereferences to human-readable docs)
        title:
          type: string
          description: Short, human-readable summary — stable across occurrences
        status:
          type: integer
          description: HTTP status code, mirroring the response status
        detail:
          type: string
          description: Human-readable explanation specific to this occurrence
        instance:
          type: string
          format: uri
          description: URI reference identifying the specific request that triggered the error
        traceId:
          type: string
          description: Distributed trace identifier for log correlation
        x-retryable:
          type: boolean
          description: Whether the client should attempt an automatic retry

  responses:
    ValidationError:
      description: Malformed or semantically invalid request payload
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetails'
          example:
            type: /errors/validation-failed
            title: Validation Failed
            status: 400
            detail: "Field 'quantity' must be a positive integer"
            instance: /v1/orders/req_01HZ
            x-retryable: false

    ConflictError:
      description: Request conflicts with current resource state
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetails'

    RateLimitError:
      description: Request throttled — client exceeded its rate allocation
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds until the client may retry
        RateLimit-Limit:
          schema:
            type: integer
        RateLimit-Remaining:
          schema:
            type: integer
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetails'

    InternalServerError:
      description: Unexpected server-side fault
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetails'
          example:
            type: /errors/internal
            title: Internal Server Error
            status: 500
            x-retryable: true

RFC / Standard Alignment

The table below maps each status class to its governing RFC section, the correct x-retryable value for automated clients, and the mandatory response headers.

Status Code RFC 9110 Section Retryable Required Headers Notes
400 Bad Request §15.5.1 No Client payload error; retrying with the same body always fails
401 Unauthorized §15.5.2 No WWW-Authenticate Client must re-authenticate before retrying
403 Forbidden §15.5.4 No Authorization is permanent; retry without credential change is futile
404 Not Found §15.5.5 No Unless the resource is expected to be created shortly (202 pattern)
409 Conflict §15.5.10 Conditional Retryable only after client resolves the conflicting state
422 Unprocessable Content §15.5.21 No Semantically invalid body; prefer over 400 when parsing succeeds
429 Too Many Requests RFC 6585 §4 Yes Retry-After, RateLimit-* Client must honour Retry-After; exponential backoff otherwise
500 Internal Server Error §15.6.1 Yes Transient server fault; safe to retry with backoff
502 Bad Gateway §15.6.3 Yes Upstream dependency failed; retry at the gateway layer
503 Service Unavailable §15.6.4 Yes Retry-After Service degraded; Retry-After is mandatory per RFC 9110 §15.6.4
504 Gateway Timeout §15.6.5 Yes Upstream timeout; retry is safe for idempotent methods

Status Code Decision Flow

The diagram below shows how a server routes an incoming request to its correct status code family, and how the client reacts based on the x-retryable contract:

HTTP Status Code Decision Flow A flowchart showing how a server maps an incoming request to the correct HTTP status code, and how the client reacts based on the x-retryable flag in the response. Incoming Request POST /v1/orders Auth valid? No 401 / 403 Yes Payload valid? No 400 / 422 Yes Rate exceeded? Yes 429 + Retry-After No Business logic OK? Error 409 / 500–504 Yes 201 Created x-retryable: false x-retryable: false x-retryable: true x-retryable: varies

Implementation Walkthrough: Server Side

Node.js / Express — Centralized Error Handler

Map domain exceptions to status codes in a single Express error middleware so individual route handlers never hard-code status integers.

// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'node:crypto';

// Domain exception hierarchy
export class ValidationError extends Error {
  constructor(public detail: string) { super(detail); }
}
export class ConflictError extends Error {
  constructor(public detail: string) { super(detail); }
}
export class RateLimitError extends Error {
  constructor(public retryAfter: number) { super('Rate limit exceeded'); }
}

interface ProblemDetails {
  type: string;
  title: string;
  status: number;
  detail?: string;
  instance?: string;
  traceId: string;
  'x-retryable': boolean;
}

// Status code mapping table — mirrors the OpenAPI spec
const ERROR_MAP: Array<[new (...args: any[]) => Error, number, string, string, boolean]> = [
  [ValidationError,  400, '/errors/validation-failed',   'Validation Failed',          false],
  [ConflictError,    409, '/errors/conflict',             'Conflict',                   false],
  [RateLimitError,   429, '/errors/rate-limit-exceeded',  'Too Many Requests',          true],
];

export function problemDetailsMiddleware(
  err: Error,
  req: Request,
  res: Response,
  _next: NextFunction
) {
  const traceId = (req.headers['x-trace-id'] as string) ?? randomUUID();

  for (const [ErrorClass, status, type, title, retryable] of ERROR_MAP) {
    if (err instanceof ErrorClass) {
      if (err instanceof RateLimitError) {
        res.setHeader('Retry-After', err.retryAfter);
      }
      const body: ProblemDetails = {
        type, title, status,
        detail: err.message,
        instance: req.originalUrl,
        traceId,
        'x-retryable': retryable,
      };
      return res.status(status).contentType('application/problem+json').json(body);
    }
  }

  // Unrecognised errors → 500 (always retryable transient fault)
  const body: ProblemDetails = {
    type: '/errors/internal',
    title: 'Internal Server Error',
    status: 500,
    traceId,
    'x-retryable': true,
  };
  return res.status(500).contentType('application/problem+json').json(body);
}

Python / FastAPI — Exception Handler Registration

# app/error_handlers.py
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import uuid

class ValidationError(Exception):
    def __init__(self, detail: str):
        self.detail = detail

class ConflictError(Exception):
    def __init__(self, detail: str):
        self.detail = detail

class RateLimitError(Exception):
    def __init__(self, retry_after: int):
        self.retry_after = retry_after

def register_error_handlers(app: FastAPI) -> None:
    @app.exception_handler(ValidationError)
    async def validation_error_handler(req: Request, exc: ValidationError):
        return JSONResponse(
            status_code=400,
            media_type="application/problem+json",
            content={
                "type": "/errors/validation-failed",
                "title": "Validation Failed",
                "status": 400,
                "detail": exc.detail,
                "instance": str(req.url),
                "traceId": req.headers.get("x-trace-id", str(uuid.uuid4())),
                "x-retryable": False,
            }
        )

    @app.exception_handler(RateLimitError)
    async def rate_limit_handler(req: Request, exc: RateLimitError):
        headers = {"Retry-After": str(exc.retry_after)}
        return JSONResponse(
            status_code=429,
            media_type="application/problem+json",
            headers=headers,
            content={
                "type": "/errors/rate-limit-exceeded",
                "title": "Too Many Requests",
                "status": 429,
                "instance": str(req.url),
                "traceId": req.headers.get("x-trace-id", str(uuid.uuid4())),
                "x-retryable": True,
            }
        )

    @app.exception_handler(Exception)
    async def generic_handler(req: Request, exc: Exception):
        return JSONResponse(
            status_code=500,
            media_type="application/problem+json",
            content={
                "type": "/errors/internal",
                "title": "Internal Server Error",
                "status": 500,
                "traceId": req.headers.get("x-trace-id", str(uuid.uuid4())),
                "x-retryable": True,
            }
        )

Implementation Walkthrough: Client Side

TypeScript — Axios Interceptor with Typed Retry

Maps x-retryable from the spec into automatic retry scheduling before the error propagates to application code.

// src/apiClient.ts
import axios, { AxiosError, AxiosInstance } from 'axios';

interface ProblemDetails {
  type: string;
  title: string;
  status: number;
  detail?: string;
  instance?: string;
  traceId?: string;
  'x-retryable'?: boolean;
}

export class ApiError extends Error {
  constructor(
    public status: number,
    public problem: ProblemDetails,
  ) {
    super(problem.title);
    this.name = 'ApiError';
  }
}

export function buildApiClient(baseURL: string): AxiosInstance {
  const client = axios.create({ baseURL });

  client.interceptors.response.use(
    (res) => res,
    async (error: AxiosError<ProblemDetails>) => {
      const { status, data, headers } = error.response ?? {};

      if (!status || !data) throw error;

      if (data['x-retryable'] && status === 429) {
        const retryAfter = parseInt(headers?.['retry-after'] ?? '5', 10) * 1000;
        await new Promise(r => setTimeout(r, retryAfter));
        return client.request(error.config!);
      }

      throw new ApiError(status, data);
    }
  );

  return client;
}

Go — Response Decoder with Explicit Error Hierarchy

// internal/apiclient/decode.go
package apiclient

import (
    "encoding/json"
    "fmt"
    "net/http"
    "strconv"
)

type ProblemDetails struct {
    Type      string `json:"type"`
    Title     string `json:"title"`
    Status    int    `json:"status"`
    Detail    string `json:"detail,omitempty"`
    TraceID   string `json:"traceId,omitempty"`
    Retryable *bool  `json:"x-retryable,omitempty"`
}

type ErrNotFound struct{ TraceID string }
type ErrValidation struct{ Detail string; TraceID string }
type ErrRateLimited struct{ RetryAfterSec int; TraceID string }
type ErrInternal struct{ Code int; Title string; TraceID string }

func (e ErrNotFound) Error() string     { return "not found" }
func (e ErrValidation) Error() string   { return e.Detail }
func (e ErrRateLimited) Error() string  { return fmt.Sprintf("rate limited, retry in %ds", e.RetryAfterSec) }
func (e ErrInternal) Error() string     { return fmt.Sprintf("server error %d: %s", e.Code, e.Title) }

func DecodeOrderResponse(resp *http.Response, target any) error {
    defer resp.Body.Close()

    if resp.StatusCode < 400 {
        return json.NewDecoder(resp.Body).Decode(target)
    }

    var p ProblemDetails
    _ = json.NewDecoder(resp.Body).Decode(&p)

    switch resp.StatusCode {
    case http.StatusNotFound:
        return ErrNotFound{TraceID: p.TraceID}
    case http.StatusBadRequest, http.StatusUnprocessableEntity:
        return ErrValidation{Detail: p.Detail, TraceID: p.TraceID}
    case http.StatusTooManyRequests:
        after, _ := strconv.Atoi(resp.Header.Get("Retry-After"))
        return ErrRateLimited{RetryAfterSec: after, TraceID: p.TraceID}
    default:
        return ErrInternal{Code: resp.StatusCode, Title: p.Title, TraceID: p.TraceID}
    }
}

Edge-Case Handling

Bulk operations returning mixed outcomes. A POST /v1/orders/batch request containing 100 items may have some succeed and some fail. Avoid mapping the whole request to 400 or 500. Return 207 Multi-Status with per-item status codes in the body, each carrying its own ProblemDetails fragment. Document this response in the OpenAPI spec under a dedicated schema to avoid SDK generators treating it as a generic error.

Idempotent retries and 409 Conflict. When a client retries a POST after a network timeout and the server already processed the first request, returning 409 without guidance leaves the client stranded. Pair 409 with a Location header pointing to the existing resource, or use Idempotency Key Implementation to detect and short-circuit duplicate submissions, returning 200 or 201 instead.

Conditional request races. 412 Precondition Failed on If-Match / If-None-Match requests is not retryable without a fresh GET first. Declare it explicitly in the spec with x-retryable: false and a detail field that instructs clients to re-fetch before modifying. Without this, optimistic locking loops become infinite.

503 with no Retry-After. RFC 9110 §15.6.4 states Retry-After SHOULD be sent with 503. When it is absent, naive clients use aggressive exponential backoff starting at 100 ms, which can cause thousands of retries during a multi-minute outage window. Make Retry-After mandatory in the spec and enforce it with the Spectral rule below.

Validation and Testing Patterns

Spectral Rules for Contract Completeness

# .spectral.yaml
rules:
  # 429 and 503 must expose x-retryable so clients can automate retry decisions
  enforce-retryable-extension:
    description: 429 and 503 responses must declare x-retryable boolean in ProblemDetails schema
    severity: error
    given: "$.paths..responses[?(@property == '429' || @property == '503')]"
    then:
      field: "content.application/problem+json.schema.properties.x-retryable"
      function: truthy

  # 429 must ship Retry-After header definition
  enforce-retry-after-header:
    description: 429 responses must define the Retry-After response header
    severity: error
    given: "$.paths..responses['429']"
    then:
      field: headers.Retry-After
      function: truthy

  # No undocumented default fallbacks — every error code must be explicit
  no-undocumented-default:
    description: Do not rely on a bare 'default' response; declare each status code explicitly
    severity: warn
    given: "$.paths..responses"
    then:
      function: schema
      functionOptions:
        schema:
          not:
            required: [default]

  # All 4xx/5xx must use application/problem+json
  error-content-type:
    description: Error responses must use application/problem+json
    severity: error
    given: "$.paths..responses[?(@property >= '400' && @property <= '599')]"
    then:
      field: "content.application/problem+json"
      function: truthy

GitHub Actions Contract Gate

# .github/workflows/api-contract-check.yml
name: API Contract Validation
on: [pull_request]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: npm

      - name: Install tooling
        run: npm install -g @stoplight/spectral-cli@6 openapi-diff

      - name: Lint OpenAPI spec
        run: spectral lint openapi.yaml --ruleset .spectral.yaml --format stylish

      - name: Check for breaking changes
        run: |
          git show origin/main:openapi.yaml > openapi-base.yaml 2>/dev/null || cp openapi.yaml openapi-base.yaml
          openapi-diff --json openapi-base.yaml openapi.yaml > diff.json
          if grep -q '"breaking":true' diff.json; then
            echo "::error::Breaking API change detected — review diff.json before merging."
            cat diff.json
            exit 1
          fi

      - name: Run contract tests
        run: npx dredd openapi.yaml http://localhost:3000 --hookfiles=./test/dredd-hooks.js
        # Dredd exercises every declared status code against the running server

Pre-Commit Hook

#!/usr/bin/env bash
# .git/hooks/pre-commit — fail fast before pushing
set -euo pipefail
npx @stoplight/spectral-cli lint openapi.yaml --quiet --ruleset .spectral.yaml
echo "OpenAPI spec passed contract rules."

SDK Generation Impact

Explicit 4xx/5xx schemas in the spec directly control what generated SDKs produce for error handling. Without them, generators fall back to opaque types that lose all type safety.

Generator With complete error schemas Without schemas
typescript-axios Generates typed ApiError<ProblemDetails> for each status; interceptors can import the type Falls back to AxiosError<any> — no compile-time checking
go Produces per-code switch cases and named error structs Returns interface{} body — all parsing deferred to caller
python Generates ProblemDetails Pydantic model; raise_for_status() raises typed ApiException Falls back to dictmypy cannot verify field access

openapi-generator-cli Commands

# TypeScript (Axios) — supportsES6 + interfaces for better tree-shaking
openapi-generator-cli generate \
  -i openapi.yaml -g typescript-axios \
  -o ./clients/ts \
  --additional-properties=supportsES6=true,withInterfaces=true,enumPropertyNaming=UPPERCASE

# Go — generateInterfaces enables mocking in tests
openapi-generator-cli generate \
  -i openapi.yaml -g go \
  -o ./clients/go \
  --additional-properties=generateInterfaces=true,packageName=apiclient

# Python (urllib3 + Pydantic)
openapi-generator-cli generate \
  -i openapi.yaml -g python \
  -o ./clients/py \
  --additional-properties=packageName=apiclient,library=urllib3,generateSourceCodeOnly=false

Runtime Observability Integration

Status codes and retryability flags must flow through to your distributed tracing and metrics layer. Distinguishing transient faults from permanent ones enables the Client Fallback Strategies that prevent cascading failures, and aligns with the Retryable vs Non-Retryable Errors classification.

// OpenTelemetry span enrichment in Express middleware
import { trace, SpanStatusCode } from '@opentelemetry/api';

export function otelStatusMiddleware(req: Request, res: Response, next: NextFunction) {
  const span = trace.getActiveSpan();
  res.on('finish', () => {
    if (!span) return;
    span.setAttribute('http.status_code', res.statusCode);
    span.setAttribute('http.retryable', res.statusCode === 429 || res.statusCode >= 500);
    if (res.statusCode >= 500) {
      span.setStatus({ code: SpanStatusCode.ERROR, message: `HTTP ${res.statusCode}` });
    }
  });
  next();
}
# Prometheus recording rules — separate retryable from permanent errors
groups:
  - name: api_error_rates
    rules:
      - record: api:retryable_errors_total
        expr: |
          sum by (service, endpoint) (
            rate(http_requests_total{status_code=~"429|5.."}[5m])
          )
      - record: api:permanent_errors_total
        expr: |
          sum by (service, endpoint) (
            rate(http_requests_total{status_code=~"4[0-9][0-8]"}[5m])
          )

# Envoy circuit breaker — ignore 4xx client errors; trip on 5xx only
circuit_breakers:
  thresholds:
    - priority: DEFAULT
      max_retries: 3
      retry_on: "5xx,connect-failure,gateway-error"
      # 429 handled by the retry policy's x-retryable flag, not the circuit breaker
      retriable_status_codes: [429, 503, 504]
      non_retriable_status_codes: [400, 401, 403, 404, 409, 422]

Anti-Patterns Quick Reference

Anti-pattern Why it fails Correct approach
Returning 500 for client-side validation errors Inflates SLO error budgets; triggers false on-call pages; misleads circuit breakers Return 400 or 422; add a Spectral rule that blocks 500 in validation paths
Omitting Retry-After on 429 responses Clients retry at arbitrary intervals, causing the very overload that triggered throttling Mandate Retry-After in the spec header definition; enforce with Spectral
default response replacing explicit status codes Generators produce any typed errors; client teams cannot implement per-code branching Declare every code individually; only use default as a fallback safety net
Returning different error shapes per endpoint Clients need per-endpoint deserialization logic; breaks shared interceptors Central $ref to ProblemDetails; validate with JSON Schema against every response
Swallowing traceId in error middleware Support engineers cannot correlate client-reported errors to backend logs Propagate incoming x-trace-id header or generate one and return it in every ProblemDetails body
Ignoring 409 semantics and returning 400 instead Clients cannot distinguish “fix your payload” from “resolve the conflict and retry” Use 409 for state conflicts; include a Location header pointing to the conflicting resource

FAQ

How do I enforce consistent HTTP status code mapping across microservices?

Centralize OpenAPI specifications in a shared repository, apply unified Spectral rulesets across all services, and mandate contract testing in CI/CD pipelines before any deployment reaches staging. A shared $ref library for ProblemDetails and response components removes per-service drift at the schema level.

Should 4xx and 5xx errors use the same response schema?

Yes. Standardizing on RFC 7807 Problem+JSON Implementation ensures uniform parsing logic in every client while distinct type URIs still allow domain-specific routing and classification. The client only needs one deserialization path regardless of status class.

How does status code mapping impact client SDK generation?

Explicit spec definitions allow generators to produce strongly-typed error classes, monadic result types, and automatic retry scheduling. Without explicit schemas, generators fall back to any/interface{} and all error handling shifts to unsafe runtime assertions.

What CI checks prevent status code drift between releases?

Run openapi-diff against the production baseline to catch breaking removals, run Spectral with custom rules to block missing x-retryable extensions and undocumented default responses, and execute contract tests that exercise every declared status code against a real or mock server.