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
This commit is contained in:
2026-09-11 17:12:22 +00:00
co-authored by Claude Opus 5
parent a065696478
commit 926250e1a9
63 changed files with 2414 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
# 6. Errors that never reach an advice
[← 5. Content negotiation](05-content-negotiation.md) · [Index](../README.md) · Next: [7. Silent 500s →](07-silent-500s.md)
`@ControllerAdvice` runs inside the `DispatcherServlet`. Three kinds of error happen before or
around it.
## Exceptions thrown by servlet filters
[`TenantHeaderFilter`](../src/main/java/com/ankurm/problems/web/TenantHeaderFilter.java) 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`](../src/main/java/com/ankurm/problems/advice/ProblemDetailErrorController.java)
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`](output/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`](../src/main/java/com/ankurm/problems/security/ProblemDetailSecurityHandlers.java)
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`](../src/main/java/com/ankurm/problems/diag/DiagController.java) serialises one
`ProblemDetail` with one extension member three ways ([`client-decoding.txt`](output/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`.