← Back to the section

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:

FieldPurpose
typeA stable identifier for the error category — a URI or URN
statusThe HTTP status code (duplicates the response code for convenience)
titleA short name, usually matching the name of the HTTP status code
detailA human-readable explanation of what went wrong
instanceA unique identifier for this specific incident
traceIdA trace identifier — to find this request in the logs
codeA 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 field field 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:

CodeWhen
400 Bad RequestInvalid request body, wrong parameters
401 UnauthorizedToken is missing or expired — authentication is required
403 ForbiddenToken is present, but access is denied
404 Not FoundThe requested object by ID does not exist
409 ConflictConcurrent modification, duplicate resource
410 GoneThe endpoint has been removed and won't come back
429 Too Many RequestsThe request limit has been exceeded
500 Internal Server ErrorAn 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 traceId that 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. Plus traceId for tracing and instance for a unique incident.
  • The Content-Type of an error response: application/problem+json, not application/json.
  • type is a stable category identifier: a URL to documentation or urn:problem:<service>:<code>. Never about:blank.
  • code is a constant in UPPER_SNAKE_CASE for programmatic logic on the client.
  • Validation errors: 400 + code: VALIDATION_ERROR + a violations array 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 a traceId and a generic phrase. Stack traces, SQL, and file paths don't end up in the response.
  • REST API — section overview — all topics in the REST section.
  • JSON and response formats — what a successful response looks like.
  • HTTP headers — traceparent and how traceId ends up in the response.