RFC 9457: A Better Error Response Format for HTTP APIs

RFC 9457: A Better Error Response Format for HTTP APIs

Most APIs eventually end up with an error response like:

{
"message": "Something went wrong",
"error": "Bad Request",
"statusCode": 400
}

The problem is that this format is usually framework-specific. NestJS, Express, Spring, Rails, and other frameworks can all return completely different error structures.

RFC 9457, Problem Details for HTTP APIs, tries to solve that problem by defining a standard structure for HTTP API errors.

It was published in July 2023 and obsoletes RFC 7807. The main idea is simple: instead of inventing another error format for every API, use a common machine-readable structure.

The Problem It Solves

HTTP status codes tell us what broadly happened:

400 Bad Request
401 Unauthorized
403 Forbidden
404 Not Found
409 Conflict
422 Unprocessable Content
500 Internal Server Error

But 400 alone doesn't tell the client what actually went wrong.

For example:

HTTP/1.1 409 Conflict

Could mean a duplicated username, an already-existing resource, a version conflict, or something else.

RFC 9457 adds structured information to the response body so clients can understand the problem without having to parse arbitrary framework-specific formats.

The Problem Details Object

A Problem Details response is a JSON object using:

Content-Type: application/problem+json

The standard defines five members:

{
"type": "
"title": "Username already exists",
"status": 409,
"detail": "The username 'ali' is already registered.",
"instance": "/users"
}

All five members are optional, but each has a specific purpose.

type

Identifies the problem type.

{
"type": "https://example.com/problems/username-taken"
}

This should be treated as the machine-readable identifier of the problem.

A type URI should ideally resolve to documentation describing the problem and how to handle it.

If type is omitted, its value is implicitly:

about:blank

Clients should use the type URI as the primary identifier instead of trying to interpret the detail string.

title

A short, human-readable summary of the problem type.

{
"title": "Username already exists"
}

title describes the problem type, so it generally shouldn't change between occurrences of the same problem.

status

The HTTP status code associated with this problem.

{
"status": 409
}

There is an important detail here: status is advisory.

The real HTTP response status is still the source of truth:

HTTP/1.1 409 Conflict

The value in the JSON should match it when generated by the server.

detail

A human-readable explanation of this specific occurrence.

{
"detail": "The username 'ali' is already registered."
}

Don’t treat detail as a machine-readable field.

Clients shouldn’t write logic like:

if (error.detail.includes("already registered")) {
// ...
}

Use type or custom extension members instead.

instance

Identifies the specific occurrence of the problem.

{
"instance": "/users"
}

This can be useful for tracing, support, logging, or correlating a particular failure.

It is different from type:

type      -> what kind of problem is this?
instance -> which occurrence of that problem is this?

Extensions

RFC 9457 intentionally allows additional members.

For example, a validation error could look like:

{
"type": "
"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"
}
]
}

errors is not defined by RFC 9457 itself. It's an extension member defined by your API.

This is one of the most useful parts of the specification: you get a standard base format without losing the ability to represent application-specific information.

HTTP Headers

The response should use:

Content-Type: application/problem+json

The client can indicate that it supports the format with:

Accept: application/problem+json

The language of human-readable fields such as title and detail can also participate in HTTP language negotiation:

Accept-Language: en

and the response may contain:

Content-Language: en

For problems where clients should retry later, the HTTP Retry-After response header can be used:

Retry-After: 30

For example:

HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 30

The body could be:

{
"type": "
"title": "Too many requests",
"status": 429,
"detail": "Rate limit exceeded."
}

RFC 9457 doesn’t introduce a new Retry-After header; it references the existing HTTP header for appropriate problem types.

What RFC 9457 Does Not Define

RFC 9457 does not define:

  • your application’s error codes
  • your validation structure
  • your logging format
  • your exception hierarchy
  • authentication or authorization behavior
  • how your API should internally handle exceptions

It only defines a standard representation for communicating HTTP problems.

So this is perfectly valid:

{
"type": "
"title": "Validation failed",
"status": 422,
"detail": "Request validation failed.",
"code": "VALIDATION_ERROR",
"errors": [
{
"field": "email",
"message": "Invalid email address"
}
]
}

Here, code and errors are application-specific extensions.

Practical Rules for Developers

Don’t expose stack traces or internal exception messages in production.

Bad:

{
"detail": "QueryFailedError: duplicate key value violates unique constraint..."
}

Better:

{
"type": "
"title": "Resource conflict",
"status": 409,
"detail": "The username is already in use."
}

Don’t make clients parse detail.

Bad:

if (error.detail === "User already exists") {
}

Instead:

if (error.type === " {
}

Keep type stable.

A type should identify a problem class, while detail can change for each occurrence.

Also, don’t create a completely different error schema for every controller. Define a consistent Problem Details layer and use extensions where necessary.

Finally, don’t confuse an RFC 9457 response with an ordinary successful resource representation. Problem Details is specifically designed for communicating HTTP problems and is most naturally used with 4xx and 5xx responses.

Implementing RFC 9457 in NestJS

In NestJS, a clean approach is to put RFC 9457 handling in 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);
}
}

Register it globally:

app.useGlobalFilters(
new ProblemDetailsExceptionFilter(),
);

Now instead of returning framework-specific error objects such as:

{
"statusCode": 404,
"message": "User not found",
"error": "Not Found"
}

your API can consistently return:

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 can introduce your own stable problem types:

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

That gives the API a predictable error contract while still allowing NestJS exceptions and application-specific errors underneath it.


RFC 9457: A Better Error Response Format for HTTP APIs was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.

Similar Posts

Leave a Reply