RFC 9457 Explained: Standardizing HTTP API Error Responses
RFC 9457 Problem Details gives HTTP APIs one standard error format. What each field means and how to implement it properly in NestJS with a global filter.
This article is published in English.
Nearly every API eventually produces an error payload that looks something like this:
{
"message": "Something went wrong",
"error": "Bad Request",
"statusCode": 400
}
The trouble is that this shape is almost always tied to whatever framework generated it. NestJS, Express, Spring, Rails, and countless other stacks each produce their own flavor of error object, with different field names and different structures.
RFC 9457, titled Problem Details for HTTP APIs, addresses this by proposing a standardized shape for HTTP API error bodies.
The specification came out in July 2023 and replaces the earlier RFC 7807. Its core idea is straightforward: rather than every API designing its own bespoke error format, everyone can rely on one shared, machine-readable structure.
The Problem It Solves
An HTTP status code gives you a general category of what happened:
400 Bad Request
401 Unauthorized
403 Forbidden
404 Not Found
409 Conflict
422 Unprocessable Content
500 Internal Server Error
But knowing you got a 400 doesn't explain the actual cause of the failure.
Take this response, for instance:
HTTP/1.1 409 Conflict
That single status line could represent a username that's already taken, a resource that already exists, a version mismatch, or any number of other conflicts.
RFC 9457 solves this by putting structured, descriptive details inside the response body itself, so a client can figure out what went wrong without needing to reverse-engineer a framework-specific error shape.
The Problem Details Object
A response following this spec is a JSON document served with this media type:
Content-Type: application/problem+json
The specification lays out five standard members:
{
"type": "https://example.com/problems/username-taken",
"title": "Username already exists",
"status": 409,
"detail": "The username 'ali' is already registered.",
"instance": "/users"
}
None of these five fields are mandatory, but each one serves a distinct role.
type
This field identifies the category of problem being reported.
{
"type": "https://example.com/problems/username-taken"
}
Think of it as the machine-readable label for the error. Ideally, the URI in type points to real documentation explaining the problem and how a client should react to it.
When type is left out, it's treated as if it were set to:
about:blank
Clients are expected to key off this type URI as the canonical identifier, rather than trying to parse meaning out of the detail text.
title
This is a short, human-friendly label describing the class of problem.
{
"title": "Username already exists"
}
Since title describes the general problem type rather than one specific instance, it's expected to stay consistent across different occurrences of the same error.
status
This mirrors the HTTP status code tied to the error.
{
"status": 409
}
One subtlety worth noting: this field is advisory only.
The actual authoritative status is whatever the HTTP response itself carries:
HTTP/1.1 409 Conflict
When the server builds the body, the status value should line up with that real response code.
detail
This field gives a human-readable description specific to this particular occurrence of the error.
{
"detail": "The username 'ali' is already registered."
}
Don't rely on detail for any machine logic.
In particular, avoid writing client code such as:
if (error.detail.includes("already registered")) {
// ...
}
For anything that needs to be checked programmatically, rely on type or on a custom extension member instead.
instance
This field points to the specific occurrence of the problem being reported.
{
"instance": "/users"
}
It's helpful when you need to trace a failure, support a user, correlate log entries, or investigate a specific request.
It serves a different purpose than type:
type -> what kind of problem is this?
instance -> which occurrence of that problem is this?
Extensions
The specification deliberately leaves room for additional fields beyond the core set.
For instance, a validation failure response might look like this:
{
"type": "https://example.com/problems/validation-error",
"title": "Validation failed",
"status": 422,
"detail": "One or more fields are invalid.",
"errors": [
{
"field": "email",
"message": "Invalid email address"
},
{
"field": "password",
"message": "Password is too short"
}
]
}
Here, errors isn't part of the RFC 9457 spec itself — it's an extension member that your API defines on its own.
This flexibility is one of the spec's biggest strengths: you get a shared, predictable envelope while still being free to attach whatever domain-specific details your application needs.
HTTP Headers
Responses following this format should set:
Content-Type: application/problem+json
Clients can signal support for this format using:
Accept: application/problem+json
Human-readable fields like title and detail can also be negotiated via standard HTTP language headers:
Accept-Language: en
with the server replying with something like:
Content-Language: en
When a client should wait before retrying, the standard Retry-After header can be included:
Retry-After: 30
As an example:
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 30
With a body such as:
{
"type": "https://example.com/problems/rate-limit",
"title": "Too many requests",
"status": 429,
"detail": "Rate limit exceeded."
}
RFC 9457 doesn't invent a new retry mechanism — it simply reuses the existing HTTP Retry-After header where it makes sense for the given problem type.
What RFC 9457 Does Not Define
The specification stays silent on:
- application-level error codes
- how you structure validation errors
- your logging conventions
- how your exception classes are organized
- authentication or authorization logic
- the internal mechanics of how your API handles exceptions
Its scope is limited to giving you a standard shape for describing HTTP-level problems.
That means a response like this is entirely compliant:
{
"type": "https://api.example.com/problems/validation-error",
"title": "Validation failed",
"status": 422,
"detail": "Request validation failed.",
"code": "VALIDATION_ERROR",
"errors": [
{
"field": "email",
"message": "Invalid email address"
}
]
}
In this example, code and errors are extensions specific to this API, not part of the base spec.
Practical Rules for Developers
Never leak stack traces or raw exception messages into production responses.
Avoid this:
{
"detail": "QueryFailedError: duplicate key value violates unique constraint..."
}
Prefer something like:
{
"type": "https://api.example.com/problems/conflict",
"title": "Resource conflict",
"status": 409,
"detail": "The username is already in use."
}
Also avoid making clients depend on parsing the detail string.
Don't do this:
if (error.detail === "User already exists") {
}
Instead, check the type field:
if (error.type === "https://api.example.com/problems/user-exists") {
}
Treat type as a stable identifier.
A given type should always represent the same category of problem, even though detail will naturally vary between individual occurrences.
Resist the temptation to invent a new error shape for each controller or endpoint. Settle on one consistent Problem Details layer across your API, and extend it with custom fields only when genuinely needed.
Lastly, don't mix up a Problem Details response with a normal successful payload. This format exists specifically to describe failures, and it belongs with 4xx and 5xx status codes.
Implementing RFC 9457 in NestJS
A tidy way to handle this in NestJS is to centralize the logic inside a global exception filter.
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
} from '@nestjs/common';
import type { Request, Response } from 'express';
interface ProblemDetails {
type?: string;
title?: string;
status?: number;
detail?: string;
instance?: string;
[key: string]: unknown;
}
@Catch()
export class ProblemDetailsExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let detail = 'An unexpected error occurred.';
let extensions: Record<string, unknown> = {};
if (exception instanceof HttpException) {
status = exception.getStatus();
const errorResponse = exception.getResponse();
if (typeof errorResponse === 'string') {
detail = errorResponse;
} else if (typeof errorResponse === 'object' && errorResponse !== null) {
const body = errorResponse as Record<string, unknown>;
if (typeof body.message === 'string') {
detail = body.message;
}
const {
statusCode: _statusCode,
error: _error,
message: _message,
...rest
} = body;
extensions = rest;
}
}
const problem: ProblemDetails = {
type: 'about:blank',
title: HttpStatus[status] ?? 'HTTP Error',
status,
detail,
instance: request.originalUrl,
...extensions,
};
response
.status(status)
.type('application/problem+json')
.json(problem);
}
}
Then wire it up application-wide:
app.useGlobalFilters(
new ProblemDetailsExceptionFilter(),
);
Rather than returning NestJS's default error shape, like:
{
"statusCode": 404,
"message": "User not found",
"error": "Not Found"
}
your endpoints will consistently emit:
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "User not found",
"instance": "/users/42"
}
From there, you're free to define your own stable type URIs for specific failure cases:
https://api.example.com/problems/user-not-found
https://api.example.com/problems/validation-error
https://api.example.com/problems/username-taken
https://api.example.com/problems/rate-limit
This setup gives your API a predictable, uniform error contract, while NestJS's built-in exceptions and your custom application errors continue to work underneath it.