Standardizing Error Responses Across Microservices

Part of the RFC 7807 Problem+JSON Implementation cluster within the Error Contracts & Resilience Mapping reference.

Inconsistent error payloads across a distributed service fleet cause silent failures, break client retry logic, and defeat distributed tracing. This guide walks through the exact sequence — canonical schema definition, drift detection, gateway enforcement, CI guardrails, and type-safe client generation — to bring an entire microservice estate onto a single application/problem+json contract.

When Does This Problem Surface?

Structural error drift becomes critical the moment a second service joins the fleet. The table below lists the concrete symptoms and the deployment context that triggers each one.

Symptom Trigger
Client deserialization exceptions in a working service A different team ships {"error":"..."} instead of {"type":"...","status":...}
Circuit breaker trips on 200 OK with embedded errors Service wraps failures in a success envelope
Content-Type: text/html from a 5xx endpoint API gateway intercepts the upstream error and rewrites the body
Retry storm on a 400 Bad Request Client cannot distinguish retryable from terminal because there is no retryable field
Missing trace link in distributed logs instance field absent; error cannot be correlated to a span

Canonical Schema Definition

Pin the contract in OpenAPI 3.1.0 before touching any service. Every microservice $refs this shared component; the schema becomes the single truth enforced by linters and CI.

# schemas/problem.yaml — OpenAPI 3.1.0 shared component
components:
  schemas:
    ProblemDetails:
      type: object
      required: [type, title, status, detail]
      properties:
        type:
          type: string
          format: uri
          description: >
            Stable URI identifying the error category. Use a URI your team controls,
            e.g. https://api.example.com/errors/validation-failed.
            Use "about:blank" only as a last resort.
          example: "https://api.example.com/errors/validation-failed"
        title:
          type: string
          description: Short, human-readable summary — constant across occurrences.
          example: "Validation Failed"
        status:
          type: integer
          description: HTTP status code; MUST match the actual response status.
          example: 422
        detail:
          type: string
          description: >
            Human-readable explanation specific to this occurrence.
            Must NOT contain stack traces, internal paths, or machine-parseable codes.
          example: "The 'email' field must be a valid email address."
        instance:
          type: string
          format: uri
          description: >
            URI identifying the specific occurrence. Use a trace-ID-based URI
            that maps to an OpenTelemetry span, e.g. /traces/{traceId}.
          example: "/traces/4bf92f3577b34da6a3ce929d0e0e4736"
        retryable:
          type: boolean
          description: >
            Extension member. true = safe to retry with backoff (e.g. 429, 503).
            false or absent = terminal error; do not retry.
      additionalProperties: false   # Rejects any legacy fields on validation

  responses:
    DefaultError:
      description: Standardized RFC 7807 error response
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetails"

Step-by-Step Resolution Guide

Step 1 — Audit Running Services for Schema Drift

Extract all 4xx/5xx responses from your gateway’s access logs and validate each body against the shared schema. The command below uses jq to isolate non-compliant responses from an Envoy log stream.

# Identify responses that are not application/problem+json
grep -E '"status":\s*(4|5)[0-9]{2}' /var/log/api-gateway/access.log \
  | jq -r 'select(.content_type != "application/problem+json")
    | [.timestamp, .service, .request_id, .status, .content_type]
    | @tsv'

Build a per-service compliance matrix from the output — track which services emit text/html, raw {"error":"..."}, or empty bodies on error paths. This matrix becomes your migration backlog.

Step 2 — Configure the Gateway to Preserve and Enforce Problem+JSON

API gateways frequently strip or override application/problem+json headers, defaulting to text/html. The Nginx block below intercepts upstream errors and rewrites them to a valid Problem+JSON body, providing a safe fallback while services migrate.

# Nginx — preserve Problem+JSON and intercept non-compliant upstream errors
location /api/ {
  proxy_intercept_errors on;
  proxy_set_header Accept "application/problem+json";

  error_page 400 401 403 404 409 422 429 500 502 503 504 = @problem_handler;
}

location @problem_handler {
  default_type application/problem+json;
  # $status is the upstream HTTP status code captured by proxy_intercept_errors
  return $status '{
    "type": "https://api.example.com/errors/gateway-intercepted",
    "title": "Gateway Error",
    "status": $status,
    "detail": "The upstream service returned an error. Check the instance URI for the trace.",
    "instance": "/traces/$request_id"
  }';
}

For Envoy, add a local_reply_config filter in your HttpConnectionManager that mirrors the same field shape.

Step 3 — Add CI Schema Validation and Breaking-Change Detection

Manual contract reviews fail at scale. The GitHub Actions workflow below blocks merges whenever a pull request introduces a structural error-schema mutation without a corresponding version bump.

# .github/workflows/error-contract.yml
name: Error Contract Validation
on: [pull_request]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Validate error fixture payloads against shared schema
        run: |
          npx ajv-cli validate \
            -s schemas/problem.yaml \
            -d "tests/fixtures/error-payloads/*.json" \
            --strict

      - name: Detect breaking changes in error response contracts
        run: |
          # Fails the PR if any DefaultError response field is removed or its type changes
          npx @openapitools/openapi-diff \
            ./specs/api-v1.yaml \
            ./specs/api-v2.yaml \
            --fail-on-incompatible

The openapi-diff step emits output like this when a breaking change is found:

[ERROR] Breaking change in /components/responses/DefaultError:
  - Removed: error (string)
  + Added:   type (string), title (string), instance (string)
  Impact: Clients parsing { "error": "..." } will throw on unmarshal.

Add this workflow to every microservice repository, using the shared schemas/problem.yaml pinned from a versioned artefact registry.

Step 4 — Generate Type-Safe Clients from the Canonical Schema

Once the schema is locked, generate language-specific error types rather than hand-writing deserialization logic. The snippets below show how to wire the generated type into a fetch interceptor.

TypeScript — fetch interceptor

// Generated from schemas/problem.yaml via openapi-typescript
interface ProblemDetails {
  type: string;        // URI identifying the error category
  title: string;
  status: number;
  detail: string;
  instance?: string;
  retryable?: boolean; // Extension member; absent = treat as false
}

async function handleResponse(res: Response): Promise<unknown> {
  if (!res.ok) {
    const ct = res.headers.get("content-type") ?? "";
    if (ct.includes("application/problem+json")) {
      const problem = (await res.json()) as ProblemDetails;
      // Only retry when the server explicitly permits it
      throw new ApiError(problem.type, problem.status, problem.detail, problem.retryable ?? false);
    }
    // Non-compliant upstream — still throw, surface raw status
    throw new NetworkError(`Non-standard error response: HTTP ${res.status}`);
  }
  return res.json();
}

Python — httpx transport hook

import httpx
from dataclasses import dataclass, field
from typing import Optional

@dataclass
class ProblemDetails:
    type: str
    title: str
    status: int
    detail: str
    instance: Optional[str] = None
    retryable: bool = field(default=False)  # Extension member default

class ProblemJsonTransport(httpx.BaseTransport):
    """Wraps every HTTP 4xx/5xx response in a structured ProblemDetails exception."""

    def __init__(self, inner: httpx.BaseTransport) -> None:
        self._inner = inner

    def handle_request(self, request: httpx.Request) -> httpx.Response:
        response = self._inner.handle_request(request)
        if response.status_code >= 400:
            raw = response.json()
            problem = ProblemDetails(
                type=raw.get("type", "about:blank"),
                title=raw.get("title", "Unknown Error"),
                status=raw["status"],
                detail=raw.get("detail", ""),
                instance=raw.get("instance"),
                retryable=raw.get("retryable", False),
            )
            raise StandardizedApiError(problem)
        return response

RFC 7807 Compliance Reference

The table maps each canonical field to its RFC section and the enforcement action.

Field RFC 7807 clause Compliance rule
type §3.1 MUST be an absolute URI; about:blank is valid but strips machine-readability
title §3.1 MUST be static per type value; never interpolate request data into it
status §3.1 MUST match the HTTP response status code; mismatch = breaking contract
detail §3.1 MUST be human-readable only; no error codes, no stack traces
instance §3.1 SHOULD be present; use trace-ID URI pattern to enable log correlation
retryable §3.2 (extension) Extension member; document in your API description; safe to add without a version bump

Safety and Caching Implications

application/problem+json responses with 4xx status codes are cacheable by default under HTTP semantics — 400, 404, 405, 410, and 414 are all cache-eligible per RFC 7234. If your gateway caches error responses, a transient upstream failure can be served as a cached 503 long after the service has recovered. Always emit Cache-Control: no-store on error responses unless caching a permanent 404 (e.g. a deleted resource). For retryable vs. non-retryable errors, confirm the retryable field matches the caching intent: a cached retryable error will suppress legitimate retry attempts at the client.

SDK and Codegen Downstream Effect

Changing a required field — even to add a new required field — is a breaking change in generated SDKs. The diff below shows how adding instance as required in a subsequent API version forces a regeneration bump.

 # schemas/problem.yaml
 required:
   - type
   - title
   - status
   - detail
+  - instance   # Breaking: existing generated clients will fail validation

Generated TypeScript clients from openapi-typescript and Python clients from datamodel-code-generator will both surface a compile-time or runtime error on this change. Use additionalProperties: false in the base schema to make the inverse case — removing a field — equally visible at the codegen layer rather than silently dropping data.

SVG: Microservice Error Standardization Flow

The diagram below shows how an error originating in a downstream service travels through the gateway and reaches the client as a uniform Problem+JSON payload.

Microservice Error Standardization Flow A service emits a raw 500 error. The API gateway intercepts it, applies Problem+JSON rewriting, and returns a structured application/problem+json response to the client. A CI pipeline validates that every service schema matches the canonical ProblemDetails definition before deployment. Client TypeScript / Python request API Gateway problem+json rewrite Cache-Control: no-store X-Correlation-ID inject Service A problem+json native Service B legacy {"error":"…"} Service C text/html on 5xx application/problem+json CI Pipeline AJV validate · openapi-diff request/response raw/legacy error

Common Mistakes

Mistake Correct approach
Embedding error codes in detail Use type (a URI) for machine-readable categorization; keep detail human-readable only
Returning 200 OK with an error body Always use the semantically correct HTTP status code (422, 409, 500, etc.)
Setting status to a value that differs from the actual HTTP status status MUST equal the response’s HTTP status code per RFC 7807 §3.1
Allowing the gateway to default to text/html on upstream errors Configure proxy_intercept_errors on and an explicit Problem+JSON fallback handler
Adding new required fields in a patch release Required-field additions are breaking changes; require a version bump and notify consumers

FAQ

How do I handle legacy services that cannot adopt RFC 7807 immediately?

Deploy a gateway transformation layer that intercepts legacy error shapes (e.g. {"error":"message"} or text/html bodies) and rewrites them to valid Problem+JSON before responses reach consumers. Enforce the canonical schema in CI for every new service so the legacy surface area shrinks with each deployment cycle.

Should error responses include stack traces in the detail field?

No. Keep detail user-safe and actionable — a sentence describing what went wrong, not how. Use the instance field to embed a URI that maps to an internal log-correlation endpoint or OpenTelemetry trace ID. Expose stack traces only behind authentication on that internal endpoint.

How do I differentiate retryable from non-retryable errors in standardized payloads?

Add the optional retryable boolean extension member to your Problem+JSON schema. Signal true only for 429 Too Many Requests and 503 Service Unavailable responses where the server expects the client to back off and retry. Treat any absent retryable field as false. Wire this flag explicitly into configuring exponential backoff for 5xx errors in your generated client interceptors.

What CI checks prevent error contract drift across teams?

Run AJV schema validation against all error fixture payloads and openapi-diff breaking-change detection in every pull-request pipeline. Block merges on any structural change to DefaultError response schemas that lacks a version bump. Pair with a Spectral rule that asserts every 4xx/5xx response definition $refs the shared ProblemDetails component rather than an inline schema.