Skip to main content

Standardizing HTTP Error Codes in OpenAPI Definitions

The symptom is unmistakable: ContractMismatchError: Expected status 400, received 422 in CI, SDK generators emitting any for error payloads, and frontend teams writing per-endpoint error-parsing logic because no two routes fail the same way. This guide is part of Designing Robust Error Response Contracts and shows the exact mechanics of fixing inconsistent HTTP status code mappings in OpenAPI 3.x — reusable response components, a documented status taxonomy, and a Spectral lint rule that prevents the problem from returning.

[ERROR] Pact::ContractMismatchError: Expected status 400, but received 422
[WARN] openapi-typescript: No schema for response '409'. Emitting 'unknown' fallback.
[FAIL] CI Pipeline: Contract validation failed on POST /api/v1/users

Root Cause

The underlying issue is always the same: the OpenAPI document uses either a catch-all default key or a single '400' entry to represent every client-side failure. Neither approach binds a specific HTTP status code to a specific schema, which breaks three categories of tooling simultaneously.

Contract validation engines (Pact, Dredd, Prism) compare the actual status code returned by the server against the explicit keys in the responses object. A default key is a syntactically valid fallback, but strict validators do not accept it as a binding assertion. When the server correctly returns 422 for a validation failure and 409 for a duplicate-key conflict, but the spec only documents 400 and default, the validator has no explicit mapping and fails.

SDK generators (openapi-typescript 7, openapi-generator) produce typed interfaces per status code. A missing '422' key means the generator emits unknown or any for that path, destroying the type safety the client team expected. The mismatch is silent at codegen time and only surfaces at runtime.

Lint rules and API gateways that enforce response documentation have nothing to check against. The undocumented status code slips through, the gateway cannot apply per-code policies (rate limit headers on 429, Retry-After on 503), and the absence compounds across every endpoint that copies the pattern.

The semantic distinctions HTTP defines are not arbitrary:

Status Correct use
400 Bad Request Body is malformed or unparseable — broken JSON, wrong Content-Type, missing body entirely
401 Unauthorized No valid credentials supplied; client should authenticate
403 Forbidden Credentials are valid but the caller lacks permission; re-authenticating will not help
404 Not Found Target resource does not exist (or you are unwilling to confirm it does)
409 Conflict Request conflicts with current resource state — duplicate key, optimistic-lock mismatch
422 Unprocessable Content Body parses fine but fails schema or business validation; this is where errors[] lives
429 Too Many Requests Rate limited; pair with a Retry-After response header
500 Internal Server Error Unexpected server fault; never carry validation detail here
503 Service Unavailable Temporary unavailability; client may retry after a delay

Collapsing all of these into a single 400 or default entry is the root cause.

Step-by-Step Fix

Step 1 — Audit Existing Paths

Before touching the spec, identify the exact scope of the problem. Run the @stoplight/spectral-cli@6.11 bundled spectral:oas ruleset against the current document:

# Install tooling (pin versions for reproducibility)
npm install -D @stoplight/spectral-cli@6.11 @apidevtools/swagger-cli@4.0

npx spectral lint openapi.yaml --ruleset spectral:oas --fail-severity=warn

Look for warnings on operation-4xx-response and operation-default-response. Every triggered warning is an endpoint that either lacks 4xx documentation entirely or relies on a catch-all default. Record the path + method pairs — you will replace each one in Step 4.

Why this works: Spectral parses the resolved spec and walks every operation’s responses object. The built-in rule checks for at least one key in the 4xx range. Running it before changes gives you a baseline; running it after confirms zero regressions.

Step 2 — Define a Reusable Problem Schema

Add a single ProblemDetail schema under components/schemas. Base it on RFC 9457 Problem Details (the 2023 successor to RFC 7807, same wire format). Centralizing this schema is what makes SDK generators emit a single typed interface rather than per-endpoint duplicates.

# openapi.yaml (OpenAPI 3.1)
components:
  schemas:
    ProblemDetail:
      type: object
      required:
        - type      # URI identifying the problem class
        - title     # stable human-readable summary
        - status    # HTTP status code (integer, NOT string)
      additionalProperties: true   # RFC 9457 requires extensions be permitted at envelope level
      properties:
        type:
          type: string
          format: uri
          description: >
            URI that identifies the problem class. Dereferences to human docs.
            Use "about:blank" when no specific type applies.
          example: "https://api.example.com/problems/validation-failed"
        title:
          type: string
          description: >
            Short, stable summary of the problem class. Should NOT vary per occurrence.
          example: "Request validation failed"
        status:
          type: integer
          minimum: 400
          maximum: 599
          description: >
            HTTP status code duplicated in the body. Must be an integer — never a string.
          example: 422
        detail:
          type: string
          description: >
            Human-readable explanation specific to this occurrence. Safe for display;
            must not contain stack traces, SQL, or internal hostnames.
          example: "One or more request fields failed validation."
        instance:
          type: string
          format: uri-reference
          description: >
            URI reference identifying the specific request or resource that failed.
          example: "/requests/01H2XK9P3Q"
        code:
          type: string
          pattern: "^[A-Z][A-Z0-9_]+$"
          description: >
            Stable machine-readable identifier clients branch on. Meaning must never change
            once shipped — renaming is a breaking change.
          example: "VALIDATION_FAILED"
        traceId:
          type: string
          description: >
            Correlation ID that maps this error to server logs. Expose to callers;
            never expose raw stack traces or internal query text.
          example: "01H2XK9P3Q"
        errors:
          type: array
          description: "Per-field validation failures. Present on 422 responses."
          items:
            $ref: '#/components/schemas/FieldError'
    FieldError:
      type: object
      required:
        - field
        - code
      additionalProperties: false
      properties:
        field:
          type: string
          description: "Dot/bracket path to the offending input: address.zip, items[0].sku"
          example: "email"
        code:
          type: string
          pattern: "^[A-Z][A-Z0-9_]+$"
          description: "Per-field machine code. Clients switch on this, not message."
          example: "FORMAT"
        message:
          type: string
          description: "Optional human hint. Clients should prefer code."
          example: "Must be a valid email address."

Note additionalProperties: true on ProblemDetail and additionalProperties: false on FieldError. The envelope must accept extension members per RFC 9457; the field-error object has a fixed shape that generators should treat as closed.

Step 3 — Create Reusable Response Objects

With the schema defined, create one named entry per status code under components/responses. Every operation references these by $ref; no inline schemas. Using application/problem+json as the media type is not cosmetic — it tells consumers, gateways, and logging middleware that the body is a problem document, not a success response, without inspecting the payload.

# openapi.yaml — continued under components:
  responses:
    BadRequest:
      description: >
        Malformed request syntax — unparseable JSON, wrong Content-Type, or missing body.
        The server could not understand the request regardless of content.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
          example:
            type: "https://api.example.com/problems/bad-request"
            title: "Bad Request"
            status: 400
            detail: "Request body is not valid JSON."
            code: "BAD_REQUEST"

    Unauthorized:
      description: "No valid credentials supplied. Authenticate and retry."
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
          example:
            type: "https://api.example.com/problems/unauthorized"
            title: "Unauthorized"
            status: 401
            code: "UNAUTHORIZED"

    Forbidden:
      description: "Caller lacks permission for this operation. Re-authenticating will not help."
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
          example:
            type: "https://api.example.com/problems/forbidden"
            title: "Forbidden"
            status: 403
            code: "FORBIDDEN"

    NotFound:
      description: "Target resource does not exist."
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
          example:
            type: "https://api.example.com/problems/not-found"
            title: "Not Found"
            status: 404
            code: "NOT_FOUND"

    Conflict:
      description: "Request conflicts with current resource state — duplicate key, version mismatch."
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
          example:
            type: "https://api.example.com/problems/conflict"
            title: "Conflict"
            status: 409
            detail: "A user with this email already exists."
            code: "USER_EMAIL_TAKEN"

    UnprocessableContent:
      description: >
        Well-formed body that fails schema or business validation.
        The errors array carries per-field detail.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
          example:
            type: "https://api.example.com/problems/validation-failed"
            title: "Request validation failed"
            status: 422
            detail: "One or more fields are invalid."
            code: "VALIDATION_FAILED"
            errors:
              - field: "email"
                code: "FORMAT"
                message: "Must be a valid email address."
              - field: "age"
                code: "MIN"
                message: "Must be at least 18."

    TooManyRequests:
      description: "Rate limited. Retry after the interval indicated in the Retry-After header."
      headers:
        Retry-After:
          schema:
            type: integer
          description: "Seconds to wait before retrying."
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
          example:
            type: "https://api.example.com/problems/rate-limited"
            title: "Too Many Requests"
            status: 429
            code: "RATE_LIMITED"

    ServerError:
      description: >
        Unexpected server fault. The body is structured for consistent client parsing
        but contains no internal details.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
          example:
            type: "https://api.example.com/problems/server-error"
            title: "Internal Server Error"
            status: 500
            code: "SERVER_ERROR"

Step 4 — Replace Inline and Catch-All Responses

Now replace every default and every repeated inline schema in your paths with the appropriate $ref. Here is the full before/after for a POST /users endpoint.

Before — the broken pattern:

paths:
  /users:
    post:
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUserRequest'
      responses:
        '201':
          description: User created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '400':
          description: Bad request        # inline, no schema — generator emits `any`
        default:
          description: Unexpected error   # catch-all breaks strict contract engines

After — the correct pattern:

paths:
  /users:
    post:
      operationId: createUser
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUserRequest'
      responses:
        '201':
          description: User created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '400':
          $ref: '#/components/responses/BadRequest'    # malformed JSON, wrong Content-Type
        '409':
          $ref: '#/components/responses/Conflict'      # email already exists
        '422':
          $ref: '#/components/responses/UnprocessableContent'  # validation failures
        '500':
          $ref: '#/components/responses/ServerError'

Each operation now carries an explicit, typed binding for every status code it can emit. The default key is gone. SDK generators see four distinct response types and produce four distinct interfaces. Contract validators see exact codes and pass.

Step 5 — Enforce with a Spectral Rule in CI

A lint rule converts “every operation documents its errors” from a code-review convention into a build gate. The built-in operation-4xx-response rule in spectral:oas checks for any 4xx key; this custom rule additionally verifies that each documented error response uses application/problem+json rather than application/json, closing the media-type loophole.

# .spectral.yaml (Spectral 6.11)
extends:
  - spectral:oas

rules:
  # Rule 1: every operation must document at least one 4xx status code
  operation-has-explicit-4xx:
    description: >
      Every operation must document at least one explicit 4xx error response.
      Catch-all 'default' does not satisfy this rule.
    message: "Operation '{{path}}' is missing an explicit 4xx response."
    given: "$.paths[*][get,post,put,patch,delete]"
    severity: error
    then:
      field: "responses"
      function: schema
      functionOptions:
        schema:
          type: object
          patternProperties:
            "^4[0-9]{2}$": {}
          minProperties: 1

  # Rule 2: every documented 4xx/5xx response must use application/problem+json
  error-response-uses-problem-json:
    description: >
      Error responses (4xx and 5xx) must declare application/problem+json as the
      content media type, not application/json.
    message: "Error response '{{path}}' should use application/problem+json, not application/json."
    given: "$.paths[*][get,post,put,patch,delete].responses[?(@property >= '400')]"
    severity: warn
    then:
      field: "content"
      function: schema
      functionOptions:
        schema:
          type: object
          required:
            - "application/problem+json"
          not:
            required:
              - "application/json"

  # Rule 3: no operation may use a catch-all 'default' as its only error response
  no-default-only-errors:
    description: >
      An operation using only 'default' as its error response is under-specified.
      Document each status code explicitly.
    message: "Operation '{{path}}' relies only on 'default' for error responses."
    given: "$.paths[*][get,post,put,patch,delete].responses"
    severity: error
    then:
      function: schema
      functionOptions:
        schema:
          type: object
          not:
            properties:
              default: {}
            required:
              - default
            maxProperties: 2   # only '200-range + default' — no explicit error codes

Wire the rule into CI so violations block merges:

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

jobs:
  lint-openapi:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - name: Lint OpenAPI spec for error coverage
        run: |
          npx spectral lint openapi.yaml \
            --ruleset .spectral.yaml \
            --fail-severity=error
      - name: Validate spec structure
        run: npx swagger-cli validate openapi.yaml

Why this works: Spectral resolves all $ref pointers before applying rules, so a response defined via $ref: '#/components/responses/UnprocessableContent' is evaluated with its full content inline. The rule sees application/problem+json and passes. An inline schema using application/json fails immediately.

Before / After at a Glance

Dimension Before After
Error response definition Inline description only, or single '400' Named $ref per status code
Schema None or repeated inline Single ProblemDetail in components/schemas
Media type application/json application/problem+json
SDK generator output any / unknown for errors Named typed interface per status code
Contract test result ContractMismatchError: Expected 400, received 422 All status codes pass
Catch-all default Present on every operation Removed; explicit codes only
Spectral lint 12 warnings, 0 enforcement 0 warnings, enforced in CI

Verification

After applying all five steps, run the full check sequence:

# 1. Validate spec structure (no broken $ref, no schema errors)
npx swagger-cli validate openapi.yaml

# 2. Lint for error coverage and media type compliance
npx spectral lint openapi.yaml --ruleset .spectral.yaml --fail-severity=error

# 3. Confirm generated types include named error interfaces
npx openapi-typescript openapi.yaml --output src/api-types.ts
grep "ProblemDetail\|UnprocessableContent\|Conflict" src/api-types.ts

Expected clean output:

openapi.yaml is valid
No results with a severity of 'error' were found.
export interface ProblemDetail { ... }
export type responses = {
  UnprocessableContent: { content: { 'application/problem+json': ProblemDetail } };
  Conflict: { content: { 'application/problem+json': ProblemDetail } };
  ...
}

Run a mock server to confirm a forced error returns the structured envelope:

docker run --rm -p 4010:4010 -v "$PWD/openapi.yaml:/api.yaml" \
  stoplight/prism:5 mock -h 0.0.0.0 /api.yaml

# Force a 422 using the Prefer header
curl -s -X POST http://localhost:4010/users \
  -H 'Content-Type: application/json' \
  -H 'Prefer: code=422' \
  -d '{}' | jq .

A passing result returns a structured ProblemDetail object with status: 422 and an errors array — not a plain string or an empty body.

Edge Cases and Caveats

Patch operations and partial updates. A PATCH endpoint may return 422 for validation failures on the submitted fields and 409 if the resource has been modified since the client last read it (optimistic concurrency). Both codes need explicit entries. Do not assume that because PUT already documents 409, the PATCH on the same path inherits it — OpenAPI response objects are per-operation.

401 vs 403 and information disclosure. Some security postures deliberately return 404 instead of 403 to avoid confirming that a resource exists. If you adopt this pattern, document it explicitly in the 404 response description so that consumers know ambiguous non-existence is intentional. Using an undocumented 404 where callers expect 403 is a contract break even if it is a deliberate security choice.

Existing clients relying on the catch-all default. If live clients are calling the API and testing for default semantics (some OpenAPI clients fall through to the default handler for any undocumented code), removing default can break them even though your changes are spec-correct. Audit client behavior before the first release of the corrected spec; add an explicit '500' entry and announce the change in your API changelog.

Frequently Asked Questions

Why does using a catch-all default response break contract tests?

Contract testing engines compare actual HTTP status codes against every documented response key. A catch-all default matches any code textually but most engines treat it as a wildcard fallback, not an explicit binding. When a server returns 422 and the spec only documents 400 and default, strict validators like Pact or Prism flag the mismatch because the semantic mapping is ambiguous.

Should I use 400 or 422 for validation failures?

Use 422 Unprocessable Content when the request body is syntactically valid JSON that fails schema or business validation. Reserve 400 Bad Request for requests that cannot be parsed at all — malformed JSON, wrong Content-Type, missing body. Pick one convention and apply it uniformly; mixing them across endpoints is the primary cause of inconsistent error contracts.

Can I define one reusable Problem schema and reference it from every error response?

Yes, and you should. Define a single Problem schema under components/schemas and a set of reusable response objects under components/responses, then reference both with $ref. This is the canonical pattern in the OpenAPI 3.x specification and is the only way to guarantee generated SDKs emit typed error models instead of any.

How do I enforce that every operation documents its error responses?

Write a custom Spectral 6.11 rule targeting $.paths[*][get,post,put,patch,delete].responses and require at least one key matching ^4[0-9]{2}$. Set severity to error so spectral lint --fail-severity=error fails CI. The built-in operation-4xx-response rule in spectral:oas is a lighter starting point if you don’t need custom logic.

What happens to SDK generators when an operation lacks explicit error response schemas?

Generators such as openapi-typescript 7 and openapi-generator fall back to any or object for undocumented status codes. The typed client loses its error model entirely, forcing consumers to cast at runtime. Explicit $ref bindings per status code produce named, typed interfaces that match the actual wire format.

Is it safe to document a 500 Internal Server Error response in OpenAPI?

Yes, and it is good practice. Documenting 500 with the same Problem schema signals to consumers that even server faults return a structured body. Include it in components/responses so every operation can reference it with one line.