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
98 lines
5.2 KiB
Markdown
98 lines
5.2 KiB
Markdown
# Global exception handling with ProblemDetail (RFC 9457)
|
|
|
|
Companion project for [**Global Exception Handling with ProblemDetail (RFC 9457) in Spring Boot 4**](https://ankurm.com/spring-boot-4-problemdetail-rfc-9457-global-exception-handling/)
|
|
on ankurm.com.
|
|
|
|
One small order API with thirteen ways to fail, run under five exception-handling setups. Every
|
|
table and transcript in the article came out of [`docs/output/`](docs/output), and
|
|
`./scripts/run-all.sh` regenerates all of it.
|
|
|
|
## Versions
|
|
|
|
| | |
|
|
|---|---|
|
|
| Spring Boot | 4.1.1 |
|
|
| Spring Framework | 7.0.9 |
|
|
| Spring Security | 7.1.1 |
|
|
| Jackson | 3 (`tools.jackson`) |
|
|
| JDK | Eclipse Temurin 25.0.4.1 (LTS) |
|
|
|
|
## Quickstart
|
|
|
|
```bash
|
|
export JAVA_HOME=/path/to/jdk-25
|
|
mvn -DskipTests package
|
|
./scripts/run.sh advice,errors # the recommended setup
|
|
curl -s localhost:8080/orders/999
|
|
./scripts/run-all.sh # regenerate every transcript in docs/output/
|
|
mvn test # 16 contract tests
|
|
```
|
|
|
|
## Profiles
|
|
|
|
| Profile | What it activates |
|
|
|---|---|
|
|
| *(none)* | Spring Boot defaults: no problem details anywhere |
|
|
| `boot-flag` | `spring.mvc.problemdetails.enabled=true` - Boot's `ProblemDetailsExceptionHandler` |
|
|
| `advice` | [`GlobalExceptionHandler`](src/main/java/com/ankurm/problems/advice/GlobalExceptionHandler.java) + [problem-detail security handlers](src/main/java/com/ankurm/problems/security/ProblemDetailSecurityHandlers.java) |
|
|
| `errors` | [`ProblemDetailErrorController`](src/main/java/com/ankurm/problems/advice/ProblemDetailErrorController.java) replacing `BasicErrorController` |
|
|
| `advice,errors` | **the recommended combination** - all thirteen failures become `application/problem+json` |
|
|
| `catchall-first` | a trap: a highest-precedence catch-all advice that turns every 4xx into 500 |
|
|
| `ambiguous` | a trap: a handler that stops the application starting |
|
|
|
|
## Endpoints
|
|
|
|
| Endpoint | Fails with |
|
|
|---|---|
|
|
| `GET /orders/{id}` | `OrderNotFoundException` (domain); `TypeMismatchException` for `/orders/abc` |
|
|
| `POST /orders` | `MethodArgumentNotValidException`; `OutOfStockException` (an `ErrorResponseException`) |
|
|
| `GET /orders?limit=` | `HandlerMethodValidationException` above 100 |
|
|
| `GET /orders/boom` | `IllegalStateException` whose message must not leak |
|
|
| `GET /orders/legacy/{id}` | `ResponseStatusException` |
|
|
| any path with `X-Tenant: BAD!` | exception thrown in a servlet filter |
|
|
| `GET /admin/orders` | 401 / 403 from Spring Security |
|
|
| `GET /diag/advice` | *diagnostic* - every `@ControllerAdvice` in consultation order |
|
|
| `GET /diag/decode?path=` | *diagnostic* - what `RestClient` decodes from an error body |
|
|
| `GET /diag/mixin` | *diagnostic* - one `ProblemDetail` serialised by three mappers |
|
|
|
|
The `/diag` endpoints are for the article. Delete them before shipping anything.
|
|
|
|
## Documentation
|
|
|
|
1. [The mental model: RFC 9457 and Spring's four types](docs/01-mental-model.md)
|
|
2. [Choosing a mechanism - defaults, the Boot flag, an advice, the error controller](docs/02-choosing-a-mechanism.md)
|
|
3. [Validation errors that say something](docs/03-validation-errors.md)
|
|
4. [Problem types, message codes and the `about:blank` change in 7.0](docs/04-i18n-and-types.md)
|
|
5. [Content negotiation: why `Accept: application/xml` gets JSON](docs/05-content-negotiation.md)
|
|
6. [Errors that never reach an advice: filters, Security, and hand-built mappers](docs/06-outside-mvc.md)
|
|
7. [Silent 500s: the catch-all that makes errors invisible](docs/07-silent-500s.md)
|
|
8. [The client side: decoding problems with `RestClient`](docs/08-clients.md)
|
|
|
|
## Captured output
|
|
|
|
| File | Produced by |
|
|
|---|---|
|
|
| [`matrix-summary.txt`](docs/output/matrix-summary.txt) and `matrix-<profile>.txt` | `scripts/demo-matrix.sh` |
|
|
| [`content-negotiation.txt`](docs/output/content-negotiation.txt) | `scripts/demo-negotiation.sh` |
|
|
| [`i18n.txt`](docs/output/i18n.txt) | `scripts/demo-i18n.sh` |
|
|
| [`silent-500.txt`](docs/output/silent-500.txt) | `scripts/demo-silent-500.sh` |
|
|
| [`ambiguous-handler.txt`](docs/output/ambiguous-handler.txt) | `scripts/demo-ambiguous.sh` |
|
|
| [`client-decoding.txt`](docs/output/client-decoding.txt) | `scripts/demo-client.sh` |
|
|
| [`advice-order.txt`](docs/output/advice-order.txt) | `scripts/demo-advice-order.sh` |
|
|
| [`type-default.txt`](docs/output/type-default.txt) | `scripts/demo-type-default.sh` |
|
|
| [`errors-only.txt`](docs/output/errors-only.txt) | `scripts/demo-errors-only.sh` |
|
|
|
|
## Findings worth the trip
|
|
|
|
- **`spring.mvc.problemdetails.enabled=true` covers Spring MVC's own exceptions only.** Your
|
|
domain exceptions, unexpected 500s, filter exceptions and Security's 401/403 keep Boot's
|
|
`/error` JSON - two error shapes in one API.
|
|
- **Spring Framework 7 stopped defaulting `type` to `about:blank`.** 6.2.19 returns the URI;
|
|
7.0.9 returns `null` and the member disappears from the JSON.
|
|
- **`Accept: application/xml` gets a JSON error** even with Jackson XML present, because
|
|
`application/xml` is not compatible with `application/problem+xml`.
|
|
- **A `new JsonMapper()` renders `ProblemDetail` wrongly** - `"properties":{...}` nested and
|
|
`"type":null` - so hand-written entry points must use the application's mapper.
|
|
- **A catch-all advice that forgets to log makes every 500 invisible**, and one given the highest
|
|
precedence also turns every 404, 405 and 400 into a 500.
|