RFC 7807 Problem+JSON Implementation
Part of the Error Contracts & Resilience Mapping reference. This page covers how to define, enforce, and consume application/problem+json payloads end-to-end — from OpenAPI schema through server middleware, client parsing, CI gates, and observability pipelines.
Problem framing
Without a shared error envelope, every service invents its own JSON structure: {"error": "..."}, {"message": "..."}, {"code": 4001, "description": "..."}. Consumers have to handle each variant, error classification logic leaks into every HTTP client, and automated tooling (SDKs, contract tests, dashboards) cannot act on the payload without service-specific knowledge.
RFC 7807 (superseded by RFC 9457, which preserves the same application/problem+json media type and five core fields) defines a single envelope that any HTTP client can parse, any SDK generator can type, and any circuit-breaker can inspect — without knowing which service produced it. The benefits compound when you apply the same contract across the full estate: a retry policy in a shared client library works for every service with zero per-service configuration, and retryable vs non-retryable error classification becomes a one-time decision.
Problem+JSON lifecycle diagram
Spec definition
Define a single reusable ProblemDetail component in OpenAPI 3.1. Using discriminator on the type field lets SDK generators produce narrowed union types for each named error variant.
# openapi.yaml — components section (OpenAPI 3.1.0)
components:
schemas:
ProblemDetail:
type: object
required: [type, title, status]
properties:
type:
type: string
format: uri
description: >
Stable, versioned URI that identifies the error class.
Dereferenceable documentation is recommended but not required.
example: "urn:api:errors:v1:validation-failed"
title:
type: string
description: Short, human-readable summary — same for all occurrences of this type.
example: "Validation Error"
status:
type: integer
minimum: 100
maximum: 599
description: MUST equal the HTTP status code on the response line.
example: 422
detail:
type: string
description: Human-readable explanation specific to this occurrence.
example: "Field 'quantity' must be a positive integer."
instance:
type: string
format: uri-reference
description: URI that identifies the specific request (usually the path + query string).
example: "/api/v1/orders?session=abc"
errors:
type: array
description: Extension member — field-level validation failures.
items:
type: object
required: [field, code, message]
properties:
field: { type: string }
code: { type: string }
message: { type: string }
discriminator:
propertyName: type
mapping:
"urn:api:errors:v1:validation-failed": "#/components/schemas/ValidationProblem"
"urn:api:errors:v1:not-found": "#/components/schemas/NotFoundProblem"
"urn:api:errors:v1:rate-limited": "#/components/schemas/RateLimitProblem"
ValidationProblem:
allOf:
- $ref: "#/components/schemas/ProblemDetail"
- type: object
required: [errors]
properties:
errors:
type: array
minItems: 1
items:
type: object
required: [field, code, message]
properties:
field: { type: string }
code: { type: string }
message: { type: string }
RFC / standard alignment
| RFC clause | Requirement | Implementation note |
|---|---|---|
| RFC 9457 §3.1 | type is a URI; about:blank is valid but discouraged |
Use a versioned URN or HTTPS URL under a stable domain |
| RFC 9457 §3.2 | title SHOULD NOT change between occurrences of the same type |
Store titles in a central error registry, not inline |
| RFC 9457 §3.3 | status MUST match the HTTP status line |
Enforce with middleware assertion; catch mismatches in CI |
| RFC 9457 §3.4 | detail is human-readable; strip PII and stack traces in production |
Use a trace_id extension for debug correlation |
| RFC 9457 §3.5 | instance is a URI-reference for this specific occurrence |
Inject req.originalUrl (Express) or request.path (FastAPI) |
| RFC 9457 §3.6 | Extension members are allowed; avoid clashing with reserved names | Namespace extensions: x-tenant-id, x-retry-after-ms |
| RFC 4918 §9.8 | 422 Unprocessable Content for semantic validation errors | Prefer 422 over 400 when the body is well-formed but logically invalid |
Implementation walkthrough — step 1: server-side middleware
The middleware serialises every thrown error as application/problem+json and asserts that the status body field matches the HTTP status line. The Express.js example below annotates each decision:
// src/middleware/problem-json.ts
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';
interface ProblemDetail {
type: string;
title: string;
status: number;
detail?: string;
instance: string;
trace_id: string;
errors?: Array<{ field: string; code: string; message: string }>;
}
// Central error registry — titles are fixed per type URI
const ERROR_TITLES: Record<string, string> = {
'urn:api:errors:v1:validation-failed': 'Validation Error',
'urn:api:errors:v1:not-found': 'Resource Not Found',
'urn:api:errors:v1:rate-limited': 'Rate Limit Exceeded',
'urn:api:errors:v1:internal': 'Internal Server Error',
};
export function problemJsonMiddleware(
err: any,
req: Request,
res: Response,
_next: NextFunction,
): void {
const statusCode: number = err.statusCode ?? 500;
const typeUri: string =
err.type ?? `urn:api:errors:v1:${statusCode === 500 ? 'internal' : 'unknown'}`;
// Assertion: body.status MUST equal the HTTP status line (RFC 9457 §3.3)
const body: ProblemDetail = {
type: typeUri,
title: ERROR_TITLES[typeUri] ?? 'Error',
status: statusCode, // enforced to match res.statusCode below
detail: process.env.NODE_ENV === 'production'
? 'An error occurred. Use trace_id to look up details.'
: err.message,
instance: req.originalUrl, // RFC 9457 §3.5
trace_id: (req.headers['x-request-id'] as string) ?? uuidv4(),
...(err.errors ? { errors: err.errors } : {}),
};
// status must be set on res BEFORE json() to avoid header-already-sent issues
res
.status(statusCode) // HTTP status line
.set('Content-Type', 'application/problem+json')
.json(body); // body.status === statusCode — guaranteed
}
Python / FastAPI equivalent:
# app/middleware/problem_json.py
from fastapi import Request
from fastapi.responses import JSONResponse
from uuid import uuid4
ERROR_TITLES = {
"urn:api:errors:v1:validation-failed": "Validation Error",
"urn:api:errors:v1:not-found": "Resource Not Found",
"urn:api:errors:v1:rate-limited": "Rate Limit Exceeded",
}
async def problem_json_handler(request: Request, exc: Exception) -> JSONResponse:
status_code = getattr(exc, "status_code", 500)
type_uri = getattr(exc, "type", "urn:api:errors:v1:internal")
body = {
"type": type_uri,
"title": ERROR_TITLES.get(type_uri, "Error"),
"status": status_code, # mirrors the HTTP status line — RFC 9457 §3.3
"detail": str(exc),
"instance": str(request.url.path),
"trace_id": request.headers.get("x-request-id", str(uuid4())),
}
return JSONResponse(
status_code=status_code,
content=body,
media_type="application/problem+json",
)
Implementation walkthrough — step 2: client-side discriminated-union parsing
Clients should inspect type — not status alone — to route errors into typed exception classes. This removes if (res.status === 422) chains scattered across service code and lets SDK generators produce fully typed clients from the discriminator mapping in the spec.
// src/client/api-client.ts
import { z } from 'zod';
// Runtime schema — validates the envelope before trusting any field
const ProblemDetailSchema = z.object({
type: z.string(),
title: z.string(),
status: z.number().int().min(100).max(599),
detail: z.string().optional(),
instance: z.string().optional(),
trace_id: z.string().optional(),
errors: z.array(z.object({
field: z.string(),
code: z.string(),
message: z.string(),
})).optional(),
});
type ProblemDetail = z.infer<typeof ProblemDetailSchema>;
// Typed exception classes — one per type URI in the discriminator mapping
export class ValidationError extends Error {
constructor(public readonly problem: ProblemDetail) {
super(problem.detail ?? problem.title);
this.name = 'ValidationError';
}
}
export class RateLimitError extends Error {
constructor(public readonly problem: ProblemDetail) {
super(problem.detail ?? problem.title);
this.name = 'RateLimitError';
}
}
export class ApiError extends Error {
constructor(public readonly problem: ProblemDetail) {
super(problem.detail ?? problem.title);
this.name = 'ApiError';
}
}
const ERROR_CLASS_MAP: Record<string, new (p: ProblemDetail) => Error> = {
'urn:api:errors:v1:validation-failed': ValidationError,
'urn:api:errors:v1:rate-limited': RateLimitError,
};
export async function safeFetch<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init);
if (!res.ok) {
const raw = await res.json();
const problem = ProblemDetailSchema.parse(raw); // throws if malformed
const Ctor = ERROR_CLASS_MAP[problem.type] ?? ApiError;
throw new Ctor(problem);
}
return res.json() as Promise<T>;
}
The parse() call on line 10 of safeFetch means a malformed Problem+JSON payload — one with a missing type or a string status — throws a ZodError immediately, surfacing contract violations during development rather than letting them silently propagate as undefined field accesses at runtime.
Edge-case handling
Bulk operations that partially succeed. HTTP 207 Multi-Status is the correct code; embed an array of per-item results where each failed item carries its own embedded ProblemDetail object under a problem key rather than hoisting a single error to the top level.
Conditional request conflicts. When If-Match fails, return 412 Precondition Failed with type: "urn:api:errors:v1:precondition-failed". Include the current ETag value in an x-current-etag extension member so clients can decide whether to re-fetch or abort.
Rate limit headroom signalling. Clients implementing exponential backoff for 5xx errors also need Retry-After information for 429 responses. Add an x-retry-after-ms extension field and mirror it as the standard Retry-After header so both header-aware and body-parsing clients benefit.
Streaming and SSE responses. Problem+JSON only applies to non-streaming HTTP responses. For server-sent events, send a final event with event: error and a JSON payload that mirrors the ProblemDetail fields; document this in the OpenAPI spec under the x-event-schema extension.
Proxies and gateways overwriting error bodies. An upstream CDN or WAF may replace a 429 with its own HTML body. Add a response validation step in the client that checks Content-Type before attempting JSON.parse; fall back to a synthetic ProblemDetail with type: "urn:api:errors:v1:gateway-error" so the rest of the error-handling path stays uniform.
Validation and testing patterns
Gate every pull request on three complementary checks:
# .github/workflows/contract-validation.yml
name: API Contract Validation
on: [pull_request]
jobs:
validate-spec:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lint OpenAPI spec (Redocly)
run: npx @redocly/cli lint openapi.yaml --max-problems 0
- name: Start Prism mock server
run: npx @stoplight/prism-cli mock openapi.yaml --dynamic --port 4010 &
- name: Run property-based contract tests (Schemathesis)
run: |
npx schemathesis run http://localhost:4010 \
--checks all \
--hypothesis-seed=1 \
--hypothesis-max-examples=50
- name: Assert problem+json on all 4xx/5xx (custom script)
run: node scripts/assert-error-envelopes.js
The Spectral rule below blocks any error response that uses the wrong media type or omits required Problem+JSON fields:
# .spectral.yaml
rules:
problem-json-media-type:
message: "Error responses must use application/problem+json"
severity: error
given: "$.paths.*.*.responses[?(@property >= '400' && @property <= '599')].content"
then:
field: "application/problem+json"
function: truthy
problem-json-required-fields:
message: "ProblemDetail must require type, title, and status"
severity: error
given: "$.components.schemas.ProblemDetail.required"
then:
function: schema
functionOptions:
schema:
type: array
contains: { enum: [type, title, status] }
minContains: 3
Contract test assertion (Jest + Supertest) that enforces the status field invariant:
// tests/contract/problem-json.test.ts
import request from 'supertest';
import app from '../../src/app';
describe('Problem+JSON envelope contract', () => {
it('returns application/problem+json with matching status field on 422', async () => {
const res = await request(app)
.post('/api/v1/orders')
.send({ quantity: -1 });
expect(res.status).toBe(422);
expect(res.headers['content-type']).toContain('application/problem+json');
// RFC 9457 §3.3 — body.status MUST equal the HTTP status line
expect(res.body.status).toBe(res.status);
expect(typeof res.body.type).toBe('string');
expect(typeof res.body.title).toBe('string');
});
it('does not expose stack traces in production mode', async () => {
process.env.NODE_ENV = 'production';
const res = await request(app).get('/api/v1/orders/nonexistent');
expect(res.body.detail).not.toMatch(/Error:|at Object/);
expect(res.body.trace_id).toBeDefined();
process.env.NODE_ENV = 'test';
});
});
SDK generation impact
The discriminator mapping in the spec produces narrowed union types in every major generator:
TypeScript (openapi-generator-cli with typescript-fetch):
openapi-generator-cli generate \
-i openapi.yaml \
-g typescript-fetch \
-o ./clients/ts-sdk \
--additional-properties=useSingleRequestParameter=true,supportsES6=true,withSeparateModelsAndApi=true
Output includes a ProblemDetail base type plus ValidationProblem, NotFoundProblem, and RateLimitProblem subtypes. The generated ApiException class holds the typed payload so call sites can write if (err instanceof ValidationProblem) without casting.
Python (openapi-python-client):
openapi-python-client generate --path openapi.yaml
Generates Pydantic v2 models. The discriminator field maps to a Literal type on type in each subclass, giving MyPy full narrowing when you match problem.type:.
Go (oapi-codegen):
oapi-codegen -generate types,client -package api openapi.yaml > api/client.gen.go
Generates a ProblemDetail struct with json tags; the discriminator becomes a switch problem.Type { case "urn:api:errors:v1:validation-failed": ... } pattern in the generated client adapter.
When you add a new error variant to the discriminator mapping, running the generator against the updated spec propagates the new type through all clients — no manual SDK update required. This is the primary reason to keep error types in the spec rather than as prose-only documentation.
Observability integration
Attach type and status to telemetry so error dashboards can key on the RFC 7807 type URI rather than just HTTP status codes:
# Python — OpenTelemetry span enrichment
from opentelemetry import trace
def enrich_span_with_problem(span: trace.Span, problem: dict) -> None:
span.set_attribute("error.type", problem.get("type", ""))
span.set_attribute("error.status", problem.get("status", 0))
span.set_attribute("error.instance", problem.get("instance", ""))
span.set_attribute("error.trace_id", problem.get("trace_id", ""))
span.set_status(trace.StatusCode.ERROR, problem.get("title", "Error"))
// TypeScript — metric tagging (StatsD / Datadog)
metrics.increment('api.errors', 1, {
error_type: problem.type, // e.g. urn:api:errors:v1:validation-failed
status: String(problem.status), // "422"
service: 'order-api',
trace_id: problem.trace_id ?? '',
});
This pairs with HTTP status code mapping to let you build a two-dimensional alert: alert on high volumes of a specific type URI even when the HTTP status is the same across multiple distinct error classes, and alert on status-code anomalies even when type is unknown (e.g. a gateway overwrite).
For standardizing error responses across microservices, deploy the middleware and error registry as a shared library rather than duplicating it per service. The telemetry enrichment code above belongs in the same library so the error.type attribute namespace is consistent across the entire estate.
Anti-patterns quick-reference
| Anti-pattern | Correct approach |
|---|---|
status field in body differs from HTTP status line |
Middleware sets body.status = statusCode before calling res.status(statusCode).json(body) |
type field is a human-readable string or omitted |
Use a versioned URN: urn:api:errors:v2:auth:token-expired |
detail contains stack trace or PII |
Strip in production; surface only via trace_id correlated in your logging pipeline |
Error titles change between occurrences of the same type |
Centralise titles in an error registry; never inline them at the throw site |
| 400 Bad Request for all validation failures | Use 422 Unprocessable Content when the body is syntactically valid but semantically wrong |
No errors extension for field-level validation |
Add an errors array so clients can surface per-field messages without parsing detail |
| Generating clients without discriminated unions | Set discriminator.propertyName: type in OpenAPI and enable union-generation flags in the generator |
Ignoring Content-Type in the client before parsing |
Check Content-Type: application/problem+json before JSON.parse; handle gateway HTML errors gracefully |
FAQ
Should the type field be a URI or a simple string?
RFC 9457 specifies a URI. Use a stable, versioned namespace such as urn:api:errors:v1:validation_failed for machine readability and forward compatibility. Avoid opaque codes or human-readable strings that will shift between releases.
How should field-level validation errors be structured inside Problem+JSON?
Add an extension member — typically an errors array — containing objects with field, code, and message properties. Keep the top-level detail field as a one-sentence human summary; downstream code should key on the errors items, not on detail.
Can Problem+JSON coexist with GraphQL error formats?
Yes. Map GraphQL extensions to RFC 7807 fields at the HTTP transport layer, or embed a ProblemDetail object inside the GraphQL extensions block on HTTP 200 responses that carry partial errors.
How do I enforce RFC 7807 compliance in CI/CD without slowing down pull requests?
Run JSON Schema validation against your OpenAPI error responses with ajv-cli or Redocly lint. Cache the schema artifact and run contract tests against a Prism mock server — the combined step usually completes in under 30 seconds.
Related
- Error Contracts & Resilience Mapping — parent reference covering the full error-contract strategy
- Standardizing error responses across microservices — shared-library deployment and multi-service coordination
- HTTP Status Code Mapping — choosing the right status for each error class
- Retryable vs Non-Retryable Errors — using the
typeURI to drive retry and circuit-breaker decisions - Client Fallback Strategies — what to do after parsing a Problem+JSON error at the client boundary