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.
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.
typeis 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
{
"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
@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
@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.
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:
error.insufficient-stock.title=Nicht genügend Bestand
error.insufficient-stock.detail=Nur {0} Einheiten von {1} verfügbar, {2} angefordertKeep 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?
Can I add my own fields to a ProblemDetail?
How should errors cross service boundaries?
Related tutorials
- Rate Limiting & ThrottlingThe five rate-limiting algorithms compared, distributed limiting with Redis and Bucket4j, per-tier quotas, and the response headers clients need to behave well.
- WebClient & HTTP Client IntegrationCalling other services without taking yourself down: WebClient configuration, the four timeouts that matter, connection pool sizing, retry with backoff, and testing against a real socket.
- Pagination, Filtering & SortingPagination that stays fast at depth: why OFFSET degrades, keyset and cursor pagination, dynamic filtering with Specifications, safe sorting, and the RFC 8288 Link header.
- GraphQL with Spring BootBuilding a GraphQL API with Spring for GraphQL: schema-first mapping, solving N+1 with batch mapping, field-level authorisation, and the query limits every public endpoint needs.