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:
- A form submission returns
422and the UI must highlight the exact invalid fields —detailis a sentence, not a structured list. - Support asks “which request was this?” and there is no correlation ID in the body to paste into a trace search.
- A client hits
429or503and wants a machine-readable backoff hint alongside theRetry-Afterheader.
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:
- 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.
- Names are yours to own — except the reserved five. You must not redefine
type,title,status,detail, orinstance, 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.
Related
- RFC 7807 Problem+JSON Implementation — up-link: the end-to-end reference this extension guide extends
- Error Contracts & Resilience Mapping — up-link: the parent reference for error envelopes and resilience mapping
- Type URI Conventions for Problem Details — how the
typemember that anchors these extensions should be designed - Building a Machine-Readable Error Registry — version extension changes through a single source of truth
- Standardizing Error Responses Across Microservices — shared middleware that emits these members consistently