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

RFC 7807 Problem+JSON lifecycle Sequence diagram showing a client sending an HTTP request to a server, the server error middleware serialising the error as application/problem+json, and the client parsing the type URI to route to typed exception classes or retry logic. HTTP Client API Server Error Middleware POST /api/v1/orders throw ValidationError { type, title, status: 422, detail, errors: [...] } application/problem+json HTTP 422 + payload HTTP 422 + problem+json body parse type URI → throw ValidationError(payload)

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.