When a request fails, the client receives an error. In the past every service returned it its own way: some returned JSON with a message field, some just a string, some an empty body with a 500 code. The client had to guess the format for each API.
RFC 9457 Problem Details fixes a single structure for all errors. Any service that follows it returns predictable JSON — the client knows what to do without reading the documentation every time.
The error body structure
A response to a failed request looks like this:
{
"type": "urn:problem:order-service:order-not-found",
"status": 404,
"title": "Order not found",
"detail": "No order found with the given identifier",
"instance": "urn:uuid:9f2d6c22-8e6d-4c2a-9b41-6b9a5e2f6c10",
"traceId": "00-1f2a8b6c7d3e4f5a9b0c1d2e3f4a5b6c-7a8b9c0d1e2f3a4b-01",
"code": "ORDER_NOT_FOUND"
}
What each field means:
| Field | Purpose |
|---|---|
type | A stable identifier for the error category — a URI or URN |
status | The HTTP status code (duplicates the response code for convenience) |
title | A short name, usually matching the name of the HTTP status code |
detail | A human-readable explanation of what went wrong |
instance | A unique identifier for this specific incident |
traceId | A trace identifier — to find this request in the logs |
code | A symbolic code for programmatic logic on the client |
Content-Type for errors
An error response must carry the header Content-Type: application/problem+json, not the plain application/json. This lets the client tell from the content type that an error arrived, not a successful response.
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{
"type": "urn:problem:order-service:order-not-found",
...
}
A common mistake is returning an error with Content-Type: application/json. The client won't be able to recognize it automatically.
The type field — the category identifier
type is not a description of a specific incident but a category identifier for the error. It doesn't change: every time an order is not found, type is the same.
There are two options:
A URL to a documentation page — if you have a portal or an internal wiki that describes this error:
"type": "https://errors.example.com/order/not-found"
The page explains: what the error is, why it occurs, and how to fix it.
A URN — if there's no portal:
"type": "urn:problem:order-service:order-not-found"
"type": "urn:problem:payment-service:insufficient-balance"
The format is urn:problem:<service-name>:<error-code>. A URN doesn't require a full-blown website while still being machine-readable and unique.
What you absolutely must not use as type: about:blank. This value tells the client nothing about what happened — all machine-readability is lost.
The code field — for programmatic logic
detail is for the user; it can be in any language and can change. code, however, is for the program code on the client. It's a constant in UPPER_SNAKE_CASE:
ORDER_NOT_FOUND
VALIDATION_ERROR
RATE_LIMIT_EXCEEDED
EXT_SYSTEM_UNAVAILABLE
INSUFFICIENT_BALANCE
The client writes its logic against code rather than trying to parse the URI:
switch (error.code) {
case 'ORDER_NOT_FOUND': showNotFoundPage(); break;
case 'EXT_SYSTEM_UNAVAILABLE': showRetryButton(); break;
}
All possible values of code are listed as an enum in the OpenAPI contract — the client knows the full list in advance.
Validation errors — the violations field
When a user submits a form with several errors, it's important to return all of the problems at once, not just the first one. Otherwise the user fixes one field, hits "Submit" again — and sees the next error. A bad experience.
For validation errors, a violations array is added:
{
"type": "urn:problem:order-service:validation-error",
"status": 400,
"title": "Bad Request",
"detail": "Input validation error",
"code": "VALIDATION_ERROR",
"violations": [
{ "field": "amount", "message": "Amount must be greater than 0" },
{ "field": "deliveryAddress.zipCode", "message": "ZIP code is required" },
{ "field": "items[0].quantity", "message": "Quantity must be between 1 and 99" }
]
}
How the path to a field is expressed:
- Nested fields — with a dot:
deliveryAddress.zipCode - Array elements — with an index:
items[0].quantity - An error on the whole object — the
fieldfield is absent or an empty string
How this looks in Spring
Spring Boot ships the ProblemDetail class starting from version 6 (Spring Boot 3). A global exception handler:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ProblemDetail> handle(OrderNotFoundException ex) {
var problem = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
problem.setType(URI.create("urn:problem:order-service:order-not-found"));
problem.setTitle("Order not found");
problem.setDetail("No order found with the given identifier");
problem.setProperty("code", "ORDER_NOT_FOUND");
problem.setProperty("traceId", MDC.get("traceId"));
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problem);
}
}
Key points: MediaType.APPLICATION_PROBLEM_JSON in contentType, traceId from MDC (where tracing puts it), and code as a separate property.
Which HTTP status codes to use
The choice of code is not arbitrary. Here's what applies when:
| Code | When |
|---|---|
400 Bad Request | Invalid request body, wrong parameters |
401 Unauthorized | Token is missing or expired — authentication is required |
403 Forbidden | Token is present, but access is denied |
404 Not Found | The requested object by ID does not exist |
409 Conflict | Concurrent modification, duplicate resource |
410 Gone | The endpoint has been removed and won't come back |
429 Too Many Requests | The request limit has been exceeded |
500 Internal Server Error | An unexpected error on the server |
The 400 and 500 codes are always present. The rest depend on what the endpoint does.
Codes like 418, 422, 451, and other non-standard ones are best avoided — they cause confusion. If something doesn't fit the standard list, it's most often a 400 or a 409.
What must not end up in the error body
When an unexpected error occurs on the server, a 500 is returned. But not everything belongs in the response:
- Stack traces — the client doesn't need to see them. Instead — a
traceIdthat lets a developer find everything in the logs. - SQL queries — they reveal the internal structure of the database.
- Internal file paths — also unnecessary information for the client.
- Personal data in
detail— if the error relates to a user, write a generic message, not the specifics.
A simple rule: the 500 body contains a traceId and a generic phrase like "Internal server error." Everything else goes in the logs.
In short
- RFC 9457 Problem Details is the error body standard for REST APIs. One structure for all 4xx/5xx.
- Required fields:
type,status,title,detail,code. PlustraceIdfor tracing andinstancefor a unique incident. - The
Content-Typeof an error response:application/problem+json, notapplication/json. typeis a stable category identifier: a URL to documentation orurn:problem:<service>:<code>. Neverabout:blank.codeis a constant inUPPER_SNAKE_CASEfor programmatic logic on the client.- Validation errors:
400+code: VALIDATION_ERROR+ aviolationsarray with all fields at once. - The path to a field in
violations: a dot for nested ones (deliveryAddress.zipCode), an index for arrays (items[0].quantity). 500: only atraceIdand a generic phrase. Stack traces, SQL, and file paths don't end up in the response.
What to read next
- REST API — section overview — all topics in the REST section.
- JSON and response formats — what a successful response looks like.
- HTTP headers —
traceparentand howtraceIdends up in the response.