Files
spring-boot-demo/problem-details/docs/06-outside-mvc.md
T
asmhatreandClaude Opus 5 926250e1a9 Add problem-details: global exception handling with RFC 9457
Companion code for "Global Exception Handling with ProblemDetail (RFC 9457)
in Spring Boot 4". Thirteen failures under five handling setups (Boot
defaults, the Boot flag, a ResponseEntityExceptionHandler advice, advice
plus an ErrorController, a catch-all ordered first), validation errors,
i18n, content negotiation, Security's 401/403, silent 500s and decoding
on the client. 16 tests pin the behaviour.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01C3TETMrqVUWeFkNtz3Jbo3
2026-09-11 17:12:22 +00:00

2.9 KiB

6. Errors that never reach an advice

← 5. Content negotiation · Index · Next: 7. Silent 500s →

@ControllerAdvice runs inside the DispatcherServlet. Three kinds of error happen before or around it.

Exceptions thrown by servlet filters

TenantHeaderFilter throws for a malformed header. No advice sees it; Tomcat forwards to /error, and even the advice profile answers with Boot's JSON. The fix is to own /error: ProblemDetailErrorController implements Boot's ErrorController (org.springframework.boot.webmvc.error in Boot 4), which makes ErrorMvcAutoConfiguration skip BasicErrorController. It builds the problem from ErrorAttributes and keeps the 5xx detail generic, because the message there is whatever a filter happened to throw.

Spring Security's 401 and 403

Decided in the filter chain. By default the entry point calls sendError(401), which also ends at /error - so the error controller alone turns them into problems (errors-only.txt):

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"}

This project goes one step further with ProblemDetailSecurityHandlers so the 401 keeps a precise type and its WWW-Authenticate header.

Note it is registered for httpBasic as well as exceptionHandling: HTTP Basic has its own entry point and would otherwise bypass the one set on exceptionHandling.

Writing a ProblemDetail yourself: use the application's mapper

/diag/mixin serialises one ProblemDetail with one extension member three ways (client-decoding.txt):

    "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; the flattening of properties and the omission of empty members come from ProblemDetailJacksonMixin, which the Boot-configured mapper has and a mapper you construct does not. An entry point that does new ObjectMapper().writeValue(...) - the version in most tutorials - produces a body that is not a valid problem document. Inject the JsonMapper.