Adding Extension Members to Problem Details

This guide sits under the RFC 7807 Problem+JSON Implementation reference within Error Contracts & Resilience Mapping. It covers the one part of the problem-details format that carries most of your domain value: the extension members you bolt onto the base object to convey field-level validation, correlation IDs, and retry hints — without breaking the contract or the generated SDKs that consume it.

The base object defined by RFC 9457 (which obsoletes RFC 7807 but keeps the application/problem+json media type) has only five members: type, title, status, detail, and instance. That is deliberately thin. Everything specific to your API — which field failed, which trace to search, how long to wait before retrying — travels as an extension member. Get the naming and typing right once and every downstream client benefits; get it wrong and you leak untyped maps into every SDK.

Decision trigger

You need extension members the moment a client has to make a decision the base object cannot support. Concretely:

If any of these apply, the answer is a small, stable set of extension members — modelled in the schema and typed in your SDKs, exactly as covered in Standardizing Error Responses Across Microservices.

What extension members are (RFC 9457 §3.2)

Section 3.2 of RFC 9457 states that a problem-details object MAY contain additional members, that consumers SHOULD ignore members they do not recognise, and that extension members have no reserved semantics beyond the five base fields. Two consequences follow directly:

  1. Extensions are additive. Adding a member never breaks a conformant consumer, because unknown members are ignored. This makes them safe to ship independently of an SDK release.
  2. Names are yours to own — except the reserved five. You must not redefine type, title, status, detail, or instance, and you should treat any member the IANA problem-types work reserves as off-limits too.

A payload with extension members

Here is a 422 validation failure carrying three extensions — errors, trace_id, and retry_after_ms:

{
  "type": "https://errors.api-contract.com/validation-failed",
  "title": "Your request parameters did not validate",
  "status": 422,
  "detail": "The request body has 2 invalid fields.",
  "instance": "/v1/orders",
  "trace_id": "0af7651916cd43dd8448eb211c80319c",
  "retry_after_ms": null,
  "errors": [
    { "pointer": "/items/0/quantity", "code": "min", "detail": "must be >= 1" },
    { "pointer": "/shipping/postal_code", "code": "pattern", "detail": "invalid format" }
  ]
}

The errors array is where a client’s validation logic keys in. detail stays a one-sentence human summary; downstream code reads errors[].pointer and errors[].code, never the prose.

The OpenAPI 3.1 schema

Declare the extensions in the schema so they are part of the contract, not an undocumented convention:

# openapi/components/schemas/ProblemDetail.yaml (OpenAPI 3.1.0)
ProblemDetail:
  type: object
  required: [type, title, status]
  properties:
    type:
      type: string
      format: uri
      default: "about:blank"
    title: { type: string }
    status: { type: integer, minimum: 400, maximum: 599 }
    detail: { type: string }
    instance: { type: string, format: uri-reference }
    trace_id:
      type: string
      description: W3C trace-id (hex) for correlating with distributed traces.
    retry_after_ms:
      type: [integer, "null"]
      minimum: 0
      description: Machine-readable retry delay mirroring the Retry-After header.
    errors:
      type: array
      items:
        type: object
        required: [pointer, code]
        properties:
          pointer: { type: string, description: "RFC 6901 JSON Pointer to the offending value." }
          code: { type: string }
          detail: { type: string }
  additionalProperties: true

Keeping additionalProperties: true honours the spirit of §3.2 — a newer server may add members an older schema does not know — while the named properties give generators something concrete to emit.

Numbered implementation steps

Step 1 — Add the errors array on the server

TypeScript (Zod):

import { z } from 'zod';

export const FieldError = z.object({
  pointer: z.string(),          // RFC 6901 JSON Pointer
  code: z.string(),
  detail: z.string().optional(),
});

export const ProblemDetail = z.object({
  type: z.string().url().default('about:blank'),
  title: z.string(),
  status: z.number().int().min(400).max(599),
  detail: z.string().optional(),
  instance: z.string().optional(),
  trace_id: z.string().optional(),
  retry_after_ms: z.number().int().min(0).nullable().optional(),
  errors: z.array(FieldError).optional(),
});

export type ProblemDetail = z.infer<typeof ProblemDetail>;

Python (Pydantic v2):

from pydantic import BaseModel, Field
from typing import Optional

class FieldError(BaseModel):
    pointer: str              # RFC 6901 JSON Pointer
    code: str
    detail: Optional[str] = None

class ProblemDetail(BaseModel):
    type: str = "about:blank"
    title: str
    status: int = Field(ge=400, le=599)
    detail: Optional[str] = None
    instance: Optional[str] = None
    trace_id: Optional[str] = None
    retry_after_ms: Optional[int] = Field(default=None, ge=0)
    errors: list[FieldError] = Field(default_factory=list)

    model_config = {"extra": "allow"}  # tolerate unknown extensions

Step 2 — Populate trace_id from the active span

trace_id should be the same identifier your tracing backend indexes, so support can jump straight from an error to a trace:

from opentelemetry import trace

def current_trace_id() -> str | None:
    ctx = trace.get_current_span().get_span_context()
    if not ctx.is_valid:
        return None
    return format(ctx.trace_id, "032x")  # W3C hex trace-id

Step 3 — Emit a machine-readable retry hint

For 429/503, set retry_after_ms and the Retry-After header so both header-aware and body-aware clients agree:

function tooManyRequests(res, waitMs: number) {
  res.setHeader('Retry-After', Math.ceil(waitMs / 1000)); // seconds, per RFC 9110
  res.status(429).type('application/problem+json').json({
    type: 'https://errors.api-contract.com/rate-limited',
    title: 'Rate limit exceeded',
    status: 429,
    retry_after_ms: waitMs, // finer-grained mirror of the header
  });
}

Step 4 — Guard reserved names in CI

Add a lint step so no one accidentally overloads a base member (for example redefining status as a string enum):

# fail if any ProblemDetail extension redefines a reserved base member
RESERVED='type title status detail instance'
for name in $RESERVED; do
  yq ".ProblemDetail.properties.$name.type" openapi/components/schemas/ProblemDetail.yaml \
    | grep -qE 'string|integer|uri-reference' || {
      echo "reserved member $name has an unexpected shape"; exit 1; }
done

RFC 9457 alignment

Requirement RFC 9457 clause How this page satisfies it
Extensions are allowed and unregistered §3.2 errors, trace_id, retry_after_ms added freely
Unknown members must be ignored §3.2 SDKs use extra: allow / additionalProperties: true
Base members keep fixed semantics §3.1 Reserved-name CI guard blocks redefinition
about:blank when no specific type §4.2.1 Schema default on type
Media type unchanged from RFC 7807 §6.1 application/problem+json on every response

Safety and caching implications

Extension members are metadata about a failure, so treat error responses as Cache-Control: no-store — a trace_id or retry_after_ms is request-specific and must never be served from a shared cache. Because unknown members are ignored, adding an extension is backwards compatible, but removing or renaming one is a breaking change for any client that came to depend on it; version those changes through your error registry rather than silently. Never place secrets, stack traces, or internal hostnames in an extension — the payload crosses the trust boundary to the client.

SDK and codegen downstream effect

When the extensions live in the OpenAPI schema, generators emit typed fields instead of a loose bag:

- // before: extensions invisible to the generator
- interface ProblemDetail {
-   type: string; title: string; status: number;
-   [key: string]: unknown;   // errors, trace_id lost in here
- }

+ // after: extensions modelled in the schema
+ interface ProblemDetail {
+   type: string; title: string; status: number;
+   detail?: string; instance?: string;
+   trace_id?: string;
+   retry_after_ms?: number | null;
+   errors?: Array<{ pointer: string; code: string; detail?: string }>;
+ }

A client can now do problem.errors?.forEach(...) with full type safety, and a validation layer can reject a payload whose errors[].pointer is missing before it reaches UI code.

Common mistakes

Mistake Correct approach
Overloading detail with a JSON blob of field errors Add a structured errors array; keep detail a human sentence
Redefining a reserved member (e.g. status as a string) Never change base-member semantics; namespace new keys
Leaving extensions out of the OpenAPI schema Declare them so generators emit typed SDK fields
Putting retry_after_ms in the body but not the Retry-After header Set both; headers serve header-aware clients and proxies
Serving error responses from a shared cache Use Cache-Control: no-store — trace and retry hints are per-request

FAQ

Do extension members need to be registered anywhere?

No. RFC 9457 §3.2 lets you add extension members without any central registration, unlike the type URIs, which benefit from a registry. Consumers that do not understand an extension member must ignore it, so extensions are always additive and never a breaking change on their own. Registration only matters for problem types, not for the ad hoc members you attach to a payload.

What happens if my extension member name collides with a future RFC member?

The RFC 9457 base members — type, title, status, detail, and instance — are reserved and must keep their defined semantics. To stay safe against future additions, prefix domain-specific keys or group them under a single object such as meta, and lint your schemas so no extension redefines a reserved name. That way a later specification member can never be shadowed by one of yours.

How do I make generated SDK clients see extension members as typed fields?

Declare every extension in the OpenAPI ProblemDetail schema and keep additionalProperties deliberate. Generators then emit typed properties for errors, trace_id, and retry_after_ms instead of a loose string map, and runtime validators like Zod or Pydantic parse the payload into a concrete type your application code can branch on safely.