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.
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.
Related
- RFC 7807 Problem+JSON Implementation — parent cluster covering the full Problem+JSON spec, extension members, and type URI conventions
- Error Contracts & Resilience Mapping — pillar covering HTTP status code strategy, client fallbacks, and retryability contracts
- Retryable vs. Non-Retryable Errors — deciding which status codes and extension flags drive backoff vs. immediate failure
- Configuring Exponential Backoff for 5xx Errors — client-side backoff implementation that consumes the
retryableflag - HTTP Status Code Mapping — the authoritative status-code reference that underpins Problem+JSON
statusfield selection