Skip to main content

Global Exception Handling with ProblemDetail (RFC 9457) in Spring Boot 4

Where a Spring Boot 4 error body actually comes from, what spring.mvc.problemdetails.enabled does and does not cover, and a thirteen-failure matrix under five setups, plus the ways a tidy ProblemDetail setup hides 500s, leaks internals, or changed underneath you in Spring Framework 7.

Ask a Spring Boot API for an order that does not exist, then for one with a malformed id, then without credentials. Three failures, and in a surprisingly large number of production services, three different response bodies — one of them RFC 9457, one of them Spring Boot’s {"timestamp","status","error","path"}, one of them nothing at all. Nobody designed that. It is what you get when problem details are switched on in one place and the errors happen in another. This article is about closing those gaps on Spring Boot 4: where an error body actually comes from, what spring.mvc.problemdetails.enabled does and does not cover, and the handful of ways a tidy ProblemDetail setup hides failures, leaks internals, or changed underneath you in Spring Framework 7.
PartFor you ifCovers
1 — Beginneryou have never returned a ProblemDetailRFC 9457 in one table, Spring’s four types, the three places an error body comes from
2 — Intermediateyou turned the Boot flag on and some errors still look wrongthe thirteen-failure matrix, the recommended setup, validation errors that say something, debugging advice order
3 — Advancedyou own the API contract and its clientssilent 500s, the about:blank change in 7.0, hand-built mappers, XML negotiation, what clients can and cannot trust
Versions this was verified against. Spring Boot 4.1.1 (GA, published to Maven Central on 20 August 2026), Spring Framework 7.0.9, Spring Security 7.1.1, Jackson 3, Eclipse Temurin JDK 25.0.4.1 LTS. Versions were read from maven-metadata.xml on Maven Central, not from announcements. One comparison uses spring-web 6.2.19, loaded in jshell.

Companion code: spring-boot-demo, directory problem-details/. One order API with thirteen ways to fail, five exception-handling setups selected by profile, sixteen contract tests, and every transcript quoted below under docs/output/, regenerated by scripts/run-all.sh.

Part 1 — What a problem document is, and where error bodies come from

RFC 9457 in one table

A problem document is a JSON object served as application/problem+json (or XML as application/problem+xml). It has five members, all optional:
MemberMeaning
typeA URI naming the kind of problem. Absent means about:blank.
titleA short summary of that kind — the same for every occurrence.
statusThe HTTP status, repeated in the body.
detailA human-readable explanation of this occurrence.
instanceA URI identifying this occurrence.
Anything else is an extension member: an orderId, a list of validation errors, a correlation id. RFC 9457 obsoletes RFC 7807 without changing the wire format. What it added is a registry of common problem types, guidance on multiple problems (“the most relevant or urgent problem” should be represented), and guidance for type URIs that cannot be dereferenced. Even the XML namespace is still urn:ietf:rfc:7807.

Spring’s four types

TypeWhat it is
ProblemDetailThe body: the five members plus a properties map for extensions.
ErrorResponseAn interface — “I know my status, headers and ProblemDetail”. Every Spring MVC exception implements it.
ErrorResponseExceptionA convenient base class for your own self-describing exceptions.
ResponseEntityExceptionHandlerA @ControllerAdvice base class that renders every Spring MVC exception as a problem.
The smallest working version needs none of your own code:
spring:
  mvc:
    problemdetails:
      enabled: true
$ curl -X DELETE /orders/1
HTTP 405  Content-Type: application/problem+json
{"detail":"Method 'DELETE' is not supported.","instance":"/orders/1","status":405,"title":"Method Not Allowed"}
That is a correct RFC 9457 response, and for a while it looks like the whole job. It is about a third of it.

The three places an error body comes from

Everything else in this article follows from one fact: an HTTP error response in a Spring Boot servlet application is produced in one of three places, and a @ControllerAdvice can only influence one of them.
request servlet filters Security filter chain DispatcherServlet → controller ① @ExceptionHandler your advice, or Boot’s handler if spring.mvc.problemdetails.enabled ② written directly entry point (401) access-denied handler (403) ③ container error page: forward to /error Spring Boot’s BasicErrorController {“timestamp”,”status”,”error”,”path”} filter throws not handled sendError() Dashed arrows are the paths that end at Boot’s /error JSON unless you replace it. The flag covers only the green box, and only for Spring MVC’s own exceptions. A @ControllerAdvice of your own covers the whole green box. Nothing you put in an advice reaches the orange or red boxes – which is why a filter’s exception and a 401 keep the old shape.
Part 2 measures exactly that picture.

Part 2 — Thirteen failures, five setups, one shape

The matrix

The companion project has one small order API and thirteen ways to make it fail: a domain exception, every common Spring MVC exception, an ErrorResponseException of its own, an unexpected IllegalStateException, an exception thrown by a servlet filter, and Spring Security’s 401 and 403. One script calls all thirteen under five setups and classifies each response by its body:
failure                                    | defaults               | boot-flag              | advice                 | advice,errors          | catchall-first        
---------------------------------------------------------------------------------------------------------------------------------------------------------------------
domain exception (OrderNotFoundException)  | 500 Boot /error JSON   | 500 Boot /error JSON   | 404 problem+json       | 404 problem+json       | 500 problem+json      
type mismatch (/orders/abc)                | 400 Boot /error JSON   | 400 problem+json       | 400 problem+json       | 400 problem+json       | 500 problem+json      
invalid body (@Valid)                      | 400 Boot /error JSON   | 400 problem+json       | 400 problem+json       | 400 problem+json       | 500 problem+json      
invalid @RequestParam (@Max)               | 400 Boot /error JSON   | 400 problem+json       | 400 problem+json       | 400 problem+json       | 500 problem+json      
ErrorResponseException (OutOfStock)        | 409 Boot /error JSON   | 409 problem+json       | 409 problem+json       | 409 problem+json       | 500 problem+json      
ResponseStatusException                    | 410 Boot /error JSON   | 410 problem+json       | 410 problem+json       | 410 problem+json       | 500 problem+json      
unknown path                               | 404 Boot /error JSON   | 404 problem+json       | 404 problem+json       | 404 problem+json       | 500 problem+json      
wrong HTTP method                          | 405 Boot /error JSON   | 405 problem+json       | 405 problem+json       | 405 problem+json       | 500 problem+json      
malformed JSON                             | 400 Boot /error JSON   | 400 problem+json       | 400 problem+json       | 400 problem+json       | 500 problem+json      
unexpected exception                       | 500 Boot /error JSON   | 500 Boot /error JSON   | 500 problem+json       | 500 problem+json       | 500 problem+json      
exception in a servlet filter              | 500 Boot /error JSON   | 500 Boot /error JSON   | 500 Boot /error JSON   | 500 problem+json       | 500 Boot /error JSON  
401 no credentials                         | 401 Boot /error JSON   | 401 Boot /error JSON   | 401 problem+json       | 401 problem+json       | 401 Boot /error JSON  
403 wrong role                             | 403 Boot /error JSON   | 403 Boot /error JSON   | 403 problem+json       | 403 problem+json       | 403 Boot /error JSON  
Read it by column. defaults — Spring Boot 4.1.1 out of the box. spring.mvc.problemdetails.enabled is false in Boot’s configuration metadata, so nothing is a problem document, and the domain exception is a 500. boot-flag — the property turned on. Boot registers ProblemDetailsExceptionHandler, an empty subclass of ResponseEntityExceptionHandler, and it does exactly what its superclass does: Spring MVC’s own exceptions and any ErrorResponse. Your OrderNotFoundException, the unexpected 500, the filter and Security all keep Boot’s JSON. This is the column most APIs are actually in — two error shapes, split along a line your clients cannot see. advice — your own @RestControllerAdvice extending ResponseEntityExceptionHandler, plus Security handlers. Everything but the filter. advice,errors — the same, plus a replacement for Boot’s /error controller. Thirteen out of thirteen. catchall-first — a trap, covered below.
The Boot flag backs off the moment you write your own handler. Its configuration class carries @ConditionalOnMissingBean(ResponseEntityExceptionHandler.class) and @Order(0) (read with javap from spring-boot-webmvc-4.1.1.jar). Declare a subclass of your own and Boot’s disappears, whether or not the property is set — the advice-order transcript shows only one advice left with both profiles active. So the property is a starting point, not something to keep alongside a real handler.

The recommended setup

Extend ResponseEntityExceptionHandler so every Spring MVC exception is already right, then add your domain exceptions and a catch-all:
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(OrderNotFoundException.class)
    ProblemDetail orderNotFound(OrderNotFoundException ex) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        pd.setType(URI.create("https://ankurm.com/problems/order-not-found"));
        pd.setTitle("Order not found");
        pd.setProperty("orderId", ex.getOrderId());
        return pd;
    }

    @ExceptionHandler(Exception.class)
    ProblemDetail unexpected(Exception ex) {
        String errorId = UUID.randomUUID().toString();
        log.error("Unhandled exception, errorId={}", errorId, ex);
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
                "An unexpected error occurred. Quote errorId when reporting it.");
        pd.setProperty("errorId", errorId);
        return pd;
    }
}
$ curl -X GET /orders/999
HTTP 404  Content-Type: application/problem+json
{"detail":"Order 999 does not exist","instance":"/orders/999","status":404,"title":"Order not found","type":"https://ankurm.com/problems/order-not-found","orderId":999}
Three details are doing real work there. The type is a stable URI a client can switch on — title and detail are for people and may be translated. The instance was filled in by Spring from the request path; nobody set it. And the catch-all puts its own sentence in detail, never ex.getMessage(): the exception in this project carries a JDBC URL and a database user, which is precisely the “implementation details” RFC 9457’s security considerations warn about. Keep both catch-all and specific handlers in one class — within a class, the most specific handler wins.

Validation errors that say something

The stock body for a request with three invalid fields names none of them:
$ curl -X POST /orders -d '{"sku":"","quantity":0,"customerEmail":"nope"}'
HTTP 400  Content-Type: application/problem+json
{"detail":"Invalid request content.","instance":"/orders","status":400,"title":"Bad Request"}
The obvious fix — declare @ExceptionHandler(MethodArgumentNotValidException.class) in your advice — stops the application starting:
Caused by: java.lang.IllegalStateException: Ambiguous @ExceptionHandler method mapped for [ExceptionHandler{exceptionType=org.springframework.web.bind.MethodArgumentNotValidException, mediaType=*/*}]:
    {org.springframework.http.ProblemDetail com.ankurm.problems.advice.AmbiguousExceptionHandler.invalid(org.springframework.web.bind.MethodArgumentNotValidException),
     public final org.springframework.http.ResponseEntity org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler.handleException(java.lang.Exception,org.springframework.web.context.request.WebRequest) throws java.lang.Exception}
The base class already maps it, through a final method. Override the protected hook instead, keep the body it built, and add an extension member:
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
        HttpHeaders headers, HttpStatusCode status, WebRequest request) {
    ProblemDetail pd = ex.getBody();
    pd.setDetail("The request body has " + ex.getErrorCount() + " invalid field(s)");
    pd.setProperty("errors", ex.getFieldErrors().stream()
            .map(e -> Map.of("pointer", "#/" + e.getField().replace('.', '/'),
                             "detail", String.valueOf(e.getDefaultMessage())))
            .toList());
    return handleExceptionInternal(ex, pd, headers, status, request);
}
HTTP 400  Content-Type: application/problem+json
{"detail":"The request body has 3 invalid field(s)","instance":"/orders","status":400,"title":"Bad Request","errors":[{"pointer":"#/quantity","detail":"must be greater than or equal to 1"},{"pointer":"#/customerEmail","detail":"must be a well-formed email address"},{"pointer":"#/sku","detail":"must not be blank"}]}
The {"pointer","detail"} shape is not invented: it is the example RFC 9457 uses in its own section 3. Two notes from running it. The three errors came back in a different order on different runs, so the contract test asserts membership, not position. And a constraint placed directly on a parameter — @RequestParam @Max(100) int limit — raises a different exception, HandlerMethodValidationException (Spring Framework 6.1’s built-in method validation, no @Validated needed), with its own stock detail of "Validation failure" and its own hook, handleHandlerMethodValidationException.

The last two boxes: filters and Security

The advice cannot see the filter’s exception; Tomcat forwards it to /error. Replace Spring Boot’s controller there — declaring any ErrorController bean (org.springframework.boot.webmvc.error in Boot 4) makes ErrorMvcAutoConfiguration skip BasicErrorController:
@RestController
public class ProblemDetailErrorController implements ErrorController {

    private final ErrorAttributes errorAttributes;

    public ProblemDetailErrorController(ErrorAttributes errorAttributes) {
        this.errorAttributes = errorAttributes;
    }

    @RequestMapping("${server.error.path:${error.path:/error}}")
    ResponseEntity<ProblemDetail> error(HttpServletRequest request) {
        Map<String, Object> attrs = errorAttributes.getErrorAttributes(
                new ServletWebRequest(request), ErrorAttributeOptions.defaults());
        int status = attrs.get("status") instanceof Integer s ? s : 500;
        ProblemDetail pd = ProblemDetail.forStatus(status);
        pd.setDetail(status >= 500 ? "An unexpected error occurred." : String.valueOf(attrs.get("error")));
        if (attrs.get("path") instanceof String path) {
            pd.setInstance(URI.create(path));
        }
        return ResponseEntity.status(status).contentType(MediaType.APPLICATION_PROBLEM_JSON).body(pd);
    }
}
$ curl -X GET /orders/1  [X-Tenant: BAD!]
HTTP 500  Content-Type: application/problem+json
{"detail":"An unexpected error occurred.","instance":"/orders/1","status":500,"title":"Internal Server Error"}
Security’s default entry point calls sendError(401), which also lands on /error, so the error controller alone — no advice, no Security customisation — already turns Security’s responses into problems:
$ curl /admin/orders
HTTP/1.1 401 
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Content-Type: application/problem+json
{"detail":"Unauthorized","instance":"/admin/orders","status":401,"title":"Unauthorized"}
The companion project goes one step further with an AuthenticationEntryPoint and AccessDeniedHandler that write the problem themselves, keeping a precise type and the WWW-Authenticate header. It is registered on httpBasic(...) as well as exceptionHandling(...), because HTTP Basic carries its own entry point and ignores the other one. For the mechanics of where those handlers sit, the filter chain article walks through the order.

Debugging: which advice handled this?

The catchall-first column is a real configuration found in real code: a catch-all advice given @Order(Ordered.HIGHEST_PRECEDENCE) “so it always runs”. It turns the unknown path, the wrong method and the malformed body into 500s:
$ curl -X GET /no-such-thing
HTTP 500  Content-Type: application/problem+json
{"detail":"Something went wrong","instance":"/no-such-thing","status":500,"title":"Internal Server Error"}
NoResourceFoundException would be a 404 CatchAllFirstHandler order -2147483648 ProblemDetailsExceptionHandler order 0 never consulted @ExceptionHandler(Exception) 500 “Something went wrong” The resolver asks each advice, in order, for a handler for this exception, and uses the first advice that has one – not the most specific handler across all advices. A catch-all matches everything, so at the front of the queue it claims every framework exception too. The fix is one class, or the catch-all at the lowest precedence.
When an error has the wrong shape, find out which advice handled it before changing any of them. ControllerAdviceBean.findAnnotatedBeans(context) lists them in consultation order — the diagnostic endpoint in the companion project prints exactly that:
## profiles: catchall-first
   order -2147483648  com.ankurm.problems.advice.CatchAllFirstHandler
   order 0            org.springframework.boot.webmvc.autoconfigure.ProblemDetailsExceptionHandler

Part 3 — What the defaults do not tell you

Tidy errors can be invisible errors

When an exception escapes to the container, Tomcat logs it with its stack trace. When an @ExceptionHandler handles it, nothing logs it unless the handler does — a handled exception is, as far as the framework is concerned, no longer an error. One request to the failing endpoint per setup, counting what reached the log:
defaults (Boot /error)                                     ERROR lines: 1   stack frames: 87
boot-flag                                                  ERROR lines: 1   stack frames: 87
advice, catch-all logs with errorId                        ERROR lines: 1   stack frames: 87
advice, catch-all WITHOUT the log line                     ERROR lines: 0   stack frames: 0
catchall-first (returns 500, never logs)                   ERROR lines: 0   stack frames: 0
The last two return a well-formed 500 and leave no trace anywhere. Adding problem details to an API that used to log its failures can remove the only record of them, and nobody notices until someone asks why the error rate dashboard went quiet the week the new handler shipped.
The fingerprint is a 500 with no matching log line. Put a correlation id in the body and log the exception with the same id — that is what the errorId in the recommended catch-all is for. Support gets a string from the customer, you grep for it, and the stack trace is there.

Spring Framework 7 stopped defaulting type to about:blank

Every framework error above has no type member. On Spring Framework 6.2 each one said "type":"about:blank". Evaluated in jshell against both jars:
spring-web-6.2.19.jar  ->  getType() = about:blank
spring-web-7.0.9.jar  ->  getType() = null
Both versions’ ProblemDetailJacksonMixin carry @JsonInclude(NON_EMPTY), so 6.2 rendered the URI and 7.0 omits the member. RFC 9457 says an absent type means about:blank, so both are valid. But a contract test that matched the literal "type":"about:blank" fails on the upgrade to Spring Boot 4, and on the client side ProblemDetail.getType() is now null for these responses — so pd.getType().equals(...) throws where it used to work.

Writing a ProblemDetail yourself? Use the application’s mapper

The Security handlers and any filter that writes an error body have to serialise the ProblemDetail themselves. One object with one extension member, through three mappers:
    "Spring Boot's JsonMapper bean": "{\"detail\":\"demo\",\"status\":409,\"title\":\"Conflict\",\"sku\":\"SKU-2\"}",
    "JsonMapper.builder().build()": "{\"detail\":\"demo\",\"instance\":null,\"properties\":{\"sku\":\"SKU-2\"},\"status\":409,\"title\":\"Conflict\",\"type\":null}",
    "new JsonMapper()": "{\"detail\":\"demo\",\"instance\":null,\"properties\":{\"sku\":\"SKU-2\"},\"status\":409,\"title\":\"Conflict\",\"type\":null}"
ProblemDetail is a plain bean. Flattening properties into top-level members and dropping empty ones is the mixin’s work, and a mapper you construct does not have the mixin. The new ObjectMapper().writeValue(response.getOutputStream(), problem) line in most entry-point tutorials produces "properties":{...} and "type":null — not a valid problem document. Inject the JsonMapper Spring Boot built.

Accept: application/xml gets a JSON error

With jackson-dataformat-xml on the classpath, a successful response honours Accept: application/xml. The error response to the same request does not:
AcceptGET /orders/1 (200)GET /orders/999 (404)
application/jsonapplication/jsonapplication/problem+json
application/xmlapplication/xmlapplication/problem+json
application/problem+xmlapplication/problem+xml
image/pngapplication/problem+json, still 404
For a ProblemDetail body the producible types are application/problem+json and application/problem+xml, and application/xml is compatible with neither — problem+xml is a different subtype, not a specialisation. Rather than turn the error into a 406, Spring falls back to JSON. That is the right behaviour; the surprise is that an XML client has to ask for application/problem+xml by name.

What a client can trust

On the client side, RestClient throws HttpClientErrorException or HttpServerErrorException, and getResponseBodyAs(ProblemDetail.class) decodes the body. Extension members arrive in getProperties() — the mixin works in both directions. The trap is that decoding always succeeds. Against the defaults profile, where the server sends Boot’s plain JSON:
    "exception": "org.springframework.web.client.HttpServerErrorException$InternalServerError",
    "status": 500,
    "contentType": "application/json",
    "problemDetail": {
        "type": "null",
        "title": "Internal Server Error",
        "status": 500,
        "detail": null,
        "instance": "null",
        "properties": {
Unknown members go into properties, so any JSON object “is” a ProblemDetail. Check that the Content-Type is application/problem+json before believing you have one, and treat a null type as about:blank.

The long tail

Each of these has a chapter in the companion repository rather than a section here:
  • Translated title and detail from messages.properties, keyed by problemDetail.title.<exception class>, with arguments from getDetailMessageArguments() — and why it only applies on the ResponseEntityExceptionHandler path: chapter 4
  • Nested field paths and list indexes in validation pointers: chapter 3
  • Why HTTP Basic needs its own entry point registration: chapter 6
  • Advice ordering, and the @Order(0) on Boot’s handler: chapter 2
Should you convert an existing API? Not in one go, and not silently. The error body is part of your contract, and clients already parse Boot’s {"timestamp","status","error","path"} — switching the shape under them is a breaking change, even though it is a better shape. For a new API, start with the recommended setup on day one. For an existing one, add application/problem+json for clients that ask for it, measure who still parses the old body, and deprecate on a schedule.

The change worth making unconditionally is the logging one. If your catch-all handler does not log, fix that today.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.