Three Error Shapes, One API: Adopting RFC 9457 Problem Details
Fourth post from the compliance-ready backend kit, after nested JWTs, append-only audit logs and key rotation.
Before I fixed this, my own service was returning three mutually inconsistent error shapes, and I had not noticed because each one was correct in isolation.
| Source | Body |
|---|---|
ValidationPipe | {"message":["property extra should not exist"],"error":"Bad Request","statusCode":400} |
| my domain error filter | {"error":"TENANT_NOT_FOUND","message":"Unknown or inactive tenant: nope"} |
| Nest’s 404 fallback | {"message":"Cannot GET /api/nonexistent","error":"Not Found","statusCode":404} |
Look at error. In one it is a machine code you would branch on. In the other two it is a human phrase you would show a user. statusCode appears in two of the three. message is a string in two and an array in one.
No client can branch on any of that. And this is the normal state of an API that grew one handler at a time, which is every API.
Why the standard instead of your own envelope
I could have invented a shape. RFC 9457 won for two reasons.
It is standards-track, so generic tooling already understands application/problem+json. And when someone is assessing your API, whether that is a security questionnaire or a new integrator, “we implement the IETF problem details format” is a materially stronger answer than “here is our custom error doc”. The second one is a thing they have to read and trust. The first one is a thing they already know.
If you like HTTP doing the work rather than reinventing it, this is the same instinct as using the QUERY method instead of a POST that pretends to be a read.
title versus detail is not a stylistic split
This is the field pair people get wrong, and the RFC is explicit. Section 3.1.3 says title “SHOULD NOT change from occurrence to occurrence”.
So the error class carries them separately:
export class TenantNotFoundError extends DomainError {
constructor(tenant: string) {
super(
"Unknown or inactive tenant", // title: stable, no interpolation
`Unknown or inactive tenant: ${tenant}`, // detail: this occurrence
"TENANT_NOT_FOUND",
);
}
}
Interpolate the tenant slug into title and you have not just broken a SHOULD. You have destroyed the field’s only purpose, which is grouping. Your error tracker now has one distinct title per tenant that ever hit a 404, and nobody can tell whether that is one bug or nine hundred.
detail is where request data belongs. title describes the type of problem. Same distinction as a log message template versus its interpolated arguments.
422, not 400, for validation
This deviates from the NestJS default deliberately.
Fastify already returns 400 for a body it cannot parse. If validation failures are also 400, you have collapsed two genuinely different conditions into one status: “your JSON is broken” and “your JSON is fine and the values are wrong”. A client can act on that difference. Retrying the first is pointless; the second tells the user which field to fix.
RFC 9457’s own validation illustration in section 3.2 uses 422 the same way. Field-level detail goes in an errors extension, with RFC 6901 JSON Pointers:
{
"success": false, "code": "VALIDATION_FAILED", "status": 422,
"title": "Request validation failed",
"detail": "One or more fields are invalid",
"errors": [
{ "detail": "slug must be lowercase, start with a letter, and use only a-z, 0-9, hyphen",
"pointer": "#/slug" }
]
}
The pipe runs whitelist: true with forbidNonWhitelisted: true, so an unknown property is rejected rather than quietly dropped. Silently discarding a field the caller believed they sent is how you get a support ticket that nobody can reproduce.
One discriminator that works on every response
Success responses are { success, data, meta }. That part is ordinary.
The part worth copying: success started out only on the envelope, where it was always true and therefore told a client precisely nothing. It is now also an RFC 9457 section 3.2 extension member on every problem body, so body.success is a valid check on any response from the API.
That costs nothing, and the RFC is why: section 3.2 requires consumers to ignore extension members they do not recognise. A generic problem-details client is unaffected. Mine gets one branch instead of two.
There is a small type-level detail I liked. The envelope’s success is typed as the literal true, not boolean, because a thrown exception bypasses interceptors entirely and is rendered by the exception filter instead. The success path can never produce false, so the type says so.
meta is always present even when empty, so no client ever has to test for its existence.
Catching everything is a disclosure control
The filter is @Catch() with no argument. It renders domain errors, every framework HttpException including the 404 for an unmatched route and the 400 Fastify raises for an unparseable body, and anything unknown.
The unknown case is the one that matters:
// Unknown. Deliberately says nothing about the cause.
return {
success: false,
type: this.typeUri("INTERNAL_ERROR"),
title: "Internal server error",
status: 500,
detail: `An unexpected error occurred. Quote traceId ${traceId} when reporting it.`,
instance: `urn:uuid:${traceId}`,
code: "INTERNAL_ERROR",
traceId,
};
The stack is logged server-side against that traceId and the response carries nothing else. Without this, an unhandled throw reaches the framework’s default handler, and a Prisma error, a driver message or raw SQL text goes out to the caller. That is not untidiness, it is information disclosure, and a catch-all filter is the cheapest control against it.
The traceId is what keeps that usable: the user quotes it, you find the one log line. It is the same identifier discipline as correlating a request across traces and logs.
The type URI makes a promise, and mine was breaking it
type is a URI identifying the problem type, and section 3.1.1 says dereferencing it should yield documentation. So it is derived from the code: TENANT_NOT_FOUND becomes <base>/problems.md#tenant-not-found, pointing at a heading in a catalogue.
That derivation has a failure mode I hit. RBAC denials arrive as Nest ForbiddenException, not as one of my own error classes, so with no explicit mapping the most common authorization failure in the whole service served code: "HTTP_403" and a type pointing at #http-403. That heading does not exist. The URI resolved to a document that says nothing about the error, which breaks the single promise the field makes.
Fixed with an explicit entry, plus titles so a human reading the body gets a sentence rather than “HTTP 400”:
const CODE_BY_STATUS = {
[HttpStatus.BAD_REQUEST]: "MALFORMED_REQUEST",
[HttpStatus.FORBIDDEN]: "FORBIDDEN",
[HttpStatus.NOT_FOUND]: "ROUTE_NOT_FOUND",
// ...
};
Now the honest part, found while writing this post.
My own documentation claims a smoke assertion fails if a type anchor is missing from the catalogue, which is what would make the promise structural rather than aspirational. That assertion does not exist. What exists is a unit test asserting the derived string matches a regex. It never opens the catalogue, so it cannot know whether the heading is there.
So I diffed the codes the filter can emit against the headings that actually exist. Four are missing: method-not-allowed, not-acceptable, payload-too-large, unsupported-media-type. Any request that trips one of those gets a type URI pointing at nothing, which is exactly the #http-403 bug again, still live, in four places.
The lesson generalises past this API. A test that asserts a derived string is not a test that the target exists. If a field’s whole contract is “this resolves to documentation”, the check has to read the documentation. Mine will:
for each code the filter can emit:
assert problems.md contains a heading whose slug equals that code
That is a fifteen-line test and it would have caught five bugs, four of which shipped.
Two 429 details worth stealing
Both are small, both are in an RFC, and both are easy to get subtly wrong.
Retry-After must be a non-negative integer. RFC 6585 section 4 makes the header a MAY on a 429, and RFC 9110 section 10.2.3 defines delay-seconds as a non-negative integer. So a computed wait has to be rounded up and floored at 1. Serialise a 400ms wait naively and you emit Retry-After: 0, which tells a client to retry immediately, which is the opposite of what a rate limiter is for.
A 429 must not be cached. Same section: a 429 response “MUST NOT be stored by a cache”. So the filter sets Cache-Control: no-store. A shared cache replaying one would either hand a 429 to callers who are inside their limit, or keep serving it after the window has passed. If you are implementing the limiter itself, I wrote about token bucket versus sliding window separately.
When to opt out of your own envelope
Exactly one route opts out: /.well-known/jwks.json.
A JWK Set is { "keys": [...] } at the top level per RFC 7517. Wrap it and you produce {"success":true,"data":{"keys":[...]}}, which no standard consumer can read, jose’s own createRemoteJWKSet rejects, and which makes the application/jwk-set+json content type a false claim about the body.
I covered this bug in the key rotation post because it is the best example I have of a failure that is invisible from inside the process. The handler was correct. A unit test of the handler passed. The wrapping happened downstream in an interceptor, so only the bytes on the wire were wrong.
The rule I settled on: the bar for an exemption is “an external specification dictates this body”, not “the envelope is inconvenient here”. Every exemption is one more shape a client has to know about, which is the exact problem the contract was built to remove.
One landmine, written down on purpose
Array payloads get meta.totalCount for free, from the array’s length. Which is correct only while nothing paginates:
Note this is the length of the page returned, which equals the total only while no endpoint paginates. The first paginated route must pass a real total.
I am including that because writing the caveat next to the code is the whole practice. A totalCount that silently means “size of this page” the day someone adds pagination is a bug that ships as a feature, and the comment is what stops it.
The part worth taking with you
- Count your error shapes. Hit a validation failure, an unmatched route, and a real domain error, and compare the three bodies. If a field means different things across them, no client can branch on it.
- Keep
titlefree of request data. It is the grouping key.detailis where the specifics go. - Catch everything, and say nothing. A catch-all filter that returns only a trace id is a disclosure control, not tidiness.
- If a field promises to resolve to documentation, test against the documentation. Asserting the derived string only proves you can build a URL.
The code is in services/auth/src/common/problem-details.filter.ts and response-envelope.interceptor.ts in the kit repo. The four missing catalogue entries and the real anchor test are going in next, since a post about honest error contracts should not ship with a dishonest one.