Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Error Handling with Problem Details

Designing an error contract on RFC 7807: the standard fields, extension properties worth adding, an error catalogue, internationalised messages, and errors across service boundaries.

Intermediate5 min readUpdated
On this page

Once you have several services and several clients, an ad-hoc error format becomes a per-integration negotiation. RFC 7807 ends that by specifying the shape in advance, and Spring 6 implements it in the framework.

Key Takeaways

  • Five standard fields: type, title, status, detail, instance. Everything else is an extension.
  • type is a stable identifier clients may branch on — treat changing it as breaking.
  • Put a correlation id in every error and the detail in your logs, not the response.
  • A published error catalogue turns support tickets into self-service.
  • Translate downstream errors; never proxy them.

The standard shape

422 Unprocessable Entity — application/problem+json
{
  "type": "https://api.acme.com/errors/insufficient-stock",
  "title": "Insufficient stock",
  "status": 422,
  "detail": "Only 3 units of ABC-1234 remain; 10 were requested",
  "instance": "/api/v1/orders",
  "correlationId": "b7f3a1c2-9e04-4d1a-8f52-1d0c9a7e5b31",
  "timestamp": "2026-07-26T09:14:03.221Z",
  "retryable": false,
  "errors": [
    { "field": "lines[0].quantity", "message": "exceeds available stock", "rejectedValue": "10" }
  ]
}

The division of labour between title and detail is the part teams get wrong. title is a constant for a given type — it names the class of problem, and a client can display or log it without parsing. detail is specific to this occurrence and may include values. Putting the specifics in title makes it useless for grouping; putting nothing in detail makes the response unhelpful.

instance identifies the occurrence. The request path is the usual choice; a URI pointing at a retrievable error record is better if you keep one.

Building problems consistently

ProblemFactory.java
@Component
public class ProblemFactory {
 
    private static final URI BASE = URI.create("https://api.acme.com/errors/");
 
    private final MessageSource messages;
 
    public ProblemDetail create(HttpStatus status, String type, String detail,
                                HttpServletRequest request) {
        var problem = ProblemDetail.forStatusAndDetail(status, detail);
        problem.setType(BASE.resolve(type));
        problem.setTitle(messages.getMessage(
                "error." + type + ".title", null, type, LocaleContextHolder.getLocale()));
        problem.setInstance(URI.create(request.getRequestURI()));
        problem.setProperty("correlationId", MDC.get("correlationId"));
        problem.setProperty("timestamp", Instant.now().toString());
        // Tells a client whether a retry could ever succeed. Without it every
        // client invents its own guess, usually badly.
        problem.setProperty("retryable", status.is5xxServerError() || status.value() == 429);
        return problem;
    }
}

Routing every problem through one factory is what makes the contract hold. A hand-built ProblemDetail in one handler will be the one missing the correlation id.

Mapping the exception hierarchy

One advice, one factory, one shape — regardless of which layer raised the exception.
ApiExceptionHandler.java
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
 
    private final ProblemFactory problems;
 
    @ExceptionHandler(BusinessRuleException.class)
    public ProblemDetail businessRule(BusinessRuleException ex, HttpServletRequest request) {
        // The rule name IS the type, so a new rule needs no handler change.
        return problems.create(HttpStatus.UNPROCESSABLE_ENTITY, ex.rule(), ex.getMessage(), request);
    }
 
    @ExceptionHandler(RateLimitExceededException.class)
    public ResponseEntity<ProblemDetail> rateLimited(RateLimitExceededException ex,
                                                     HttpServletRequest request) {
        var problem = problems.create(HttpStatus.TOO_MANY_REQUESTS, "rate-limited",
                "Rate limit exceeded", request);
        return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
                .header(HttpHeaders.RETRY_AFTER, String.valueOf(ex.retryAfterSeconds()))
                .body(problem);
    }
 
    @ExceptionHandler(Exception.class)
    public ProblemDetail unexpected(Exception ex, HttpServletRequest request) {
        String id = MDC.get("correlationId");
        log.error("unhandled [{}] {} {}", id, request.getMethod(), request.getRequestURI(), ex);
        return problems.create(HttpStatus.INTERNAL_SERVER_ERROR, "internal-error",
                "An unexpected error occurred. Quote correlation id %s.".formatted(id), request);
    }
}

Errors across service boundaries

An error from a downstream service is input to your service, not output to your caller. Passing it through leaks your internal architecture, and its type URIs are meaningless to your consumer.

DownstreamTranslation.java
public Receipt charge(Order order) {
    try {
        return paymentClient.charge(order);
 
    } catch (HttpClientErrorException.UnprocessableEntity ex) {
        // A known business failure downstream becomes a known one here.
        ProblemDetail downstream = ex.getResponseBodyAs(ProblemDetail.class);
        log.info("payment declined for {}: {}", order.reference(), downstream);
        throw new BusinessRuleException("payment-declined", "The payment was declined");
 
    } catch (RestClientException ex) {
        // Anything else is our problem, not the caller's fault.
        log.error("payment service unavailable for {}", order.reference(), ex);
        throw new ServiceUnavailableException("payments", Duration.ofSeconds(30));
    }
}

The distinction the caller cares about is whether retrying could help. A declined card will be declined again; a timeout might not be. That is exactly what the retryable extension and the Retry-After header communicate.

The error catalogue

Every type URI should resolve to documentation with four sections: what the error means, the common causes, what to change, and whether a retry can succeed. Generate the page from the same enum or constant list your code uses, so a new error type cannot ship undocumented.

This is the highest-leverage documentation you can write. A consumer encountering insufficient-stock for the first time either reads a page that explains it in thirty seconds, or opens a support ticket that costs an hour of somebody's day. The page also gives you a stable place to record behaviour changes.

Internationalised messages

For consumer-facing APIs the detail may need translating. Resolve it through MessageSource from the Accept-Language header:

messages_de.properties
error.insufficient-stock.title=Nicht genügend Bestand
error.insufficient-stock.detail=Nur {0} Einheiten von {1} verfügbar, {2} angefordert

Keep type and the errors[].field values untranslated. Those are identifiers a client branches on; translating them would break every consumer that switched on them.

Security boundaries in error content

The rule is that an error must help a legitimate caller without helping an attacker enumerate your system. Three specifics follow from it.

Do not distinguish "user not found" from "wrong password" on a login endpoint. The difference is a free account-enumeration oracle. Return one message for both.

Do not confirm existence in a 403. "Order 4711 belongs to another customer" tells an attacker that order 4711 exists and roughly who owns it. Where existence is itself sensitive, return 404 for both missing and forbidden.

Never include stack traces, SQL fragments, internal hostnames or class names. These leak your dependency versions and internal structure, which is reconnaissance. Set server.error.include-stacktrace=never and include-message=never so the default error page cannot leak them either when your advice does not run.

What to take away

Adopt ProblemDetail, build every problem through one factory, and give each error class a stable type that resolves to real documentation. Add a correlation id and a retryable flag. Translate downstream errors into your own vocabulary, and keep everything an attacker could use out of the response body.

Frequently Asked Questions

Should the type URI actually resolve to a page?
Yes, and it is worth the afternoon it takes. A developer hitting an unfamiliar error pastes the URI into a browser; if it returns documentation explaining causes and fixes, you have answered a support ticket before it was written. If it 404s, you have added a broken link to every error response.
Can I add my own fields to a ProblemDetail?
Yes — that is what the specification calls extension members, and setProperty adds them. Keep the standard five doing their standard jobs and put everything else in extensions: a correlation id, a field-level errors array, a retryable flag.
How should errors cross service boundaries?
Never pass a downstream error body through unchanged. It exposes your internal topology and its type URIs mean nothing to your caller. Translate it into your own vocabulary, log the original against a correlation id, and return a problem your consumer can act on.

Related tutorials