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
+48
View File
@@ -0,0 +1,48 @@
# 1. The mental model: RFC 9457 and Spring's four types
[Index](../README.md) · Next: [2. Choosing a mechanism →](02-choosing-a-mechanism.md)
## What RFC 9457 actually specifies
A problem document is a JSON (or XML) object served as `application/problem+json`
(`application/problem+xml`) with five optional members:
| Member | Meaning |
|---|---|
| `type` | a URI identifying the *kind* of problem. When absent it is assumed to be `about:blank` |
| `title` | a short, human-readable summary of that kind - the same for every occurrence |
| `status` | the HTTP status, repeated for convenience |
| `detail` | a human-readable explanation of *this* occurrence |
| `instance` | a URI identifying this occurrence |
Anything else is an *extension member* - `orderId`, `errors`, `errorId` in this project.
RFC 9457 obsoletes RFC 7807. The wire format did not change. What it added: a registry of common
problem type URIs, guidance on multiple problems ("the most relevant or urgent problem" should be
represented), and guidance for `type` URIs that cannot be dereferenced. The XML namespace is still
`urn:ietf:rfc:7807` - you will see it in [`content-negotiation.txt`](output/content-negotiation.txt).
## Spring's four types
| Type | What it is | Used here |
|---|---|---|
| `ProblemDetail` | the body: the five members plus a `properties` map for extensions | every handler |
| `ErrorResponse` | an interface: "I know my status, headers and `ProblemDetail`" | all Spring MVC exceptions implement it |
| `ErrorResponseException` | a convenient base class implementing `ErrorResponse` | [`OutOfStockException`](../src/main/java/com/ankurm/problems/domain/OutOfStockException.java) |
| `ResponseEntityExceptionHandler` | a `@ControllerAdvice` base class that renders every Spring MVC exception as a problem | [`GlobalExceptionHandler`](../src/main/java/com/ankurm/problems/advice/GlobalExceptionHandler.java) |
## Who renders what
There are three places an error body can come from, and most confusion comes from not knowing
which one produced the response you are looking at:
1. **An `@ExceptionHandler`** - inside the `DispatcherServlet`, for exceptions thrown by handler
methods (and Spring MVC's own exceptions).
2. **The container's error page** - Tomcat forwards to `/error`, Spring Boot's
`BasicErrorController` renders `{"timestamp","status","error","path"}`. Everything the first
place did not handle ends up here, including exceptions from servlet filters.
3. **Code that writes the response directly** - Spring Security's entry point and access-denied
handler, or your own filters.
The five setups in [chapter 2](02-choosing-a-mechanism.md) differ only in which of these three
places produce `application/problem+json`.
@@ -0,0 +1,71 @@
# 2. Choosing a mechanism
[← 1. Mental model](01-mental-model.md) · [Index](../README.md) · Next: [3. Validation errors →](03-validation-errors.md)
The same thirteen failures under five setups - [`matrix-summary.txt`](output/matrix-summary.txt),
produced by [`scripts/demo-matrix.sh`](../scripts/demo-matrix.sh):
```
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
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
```
## `spring.mvc.problemdetails.enabled`
Defaults to `false` in Spring Boot 4.1.1 (`spring-configuration-metadata.json` in
`spring-boot-webmvc`). When `true`, `WebMvcAutoConfiguration$ProblemDetailsErrorHandlingConfiguration`
registers `ProblemDetailsExceptionHandler` - an empty subclass of `ResponseEntityExceptionHandler` -
with:
- `@ConditionalOnMissingBean(ResponseEntityExceptionHandler.class)` - it backs off as soon as you
declare your own subclass ([`advice-order.txt`](output/advice-order.txt), profiles
`boot-flag,advice`)
- `@Order(0)` - it is consulted before any advice with the default (lowest) precedence
It handles exactly what `ResponseEntityExceptionHandler` handles: Spring MVC's exceptions and any
`ErrorResponse`. **It does not handle your exceptions**, which is why the first and tenth rows stay
Boot's JSON. Turning the flag on and stopping there gives an API with two error shapes.
## Advice ordering, and the catch-all trap
`ExceptionHandlerExceptionResolver` walks the advices in order and uses the **first advice that has
any matching handler**, not the most specific handler across all advices. The
[`catchall-first`](../src/main/java/com/ankurm/problems/advice/CatchAllFirstHandler.java) profile
declares `@ExceptionHandler(Exception.class)` at `Ordered.HIGHEST_PRECEDENCE`; it matches
`NoResourceFoundException`, `HttpRequestMethodNotSupportedException` and every other framework
exception before Boot's handler at order 0 gets a look. Every 4xx in the matrix becomes a 500.
Keep the catch-all in the *same* class as your specific handlers (the most specific handler wins
within one class), or give it the lowest precedence.
## The handler that stops the application starting
Extending `ResponseEntityExceptionHandler` and adding
`@ExceptionHandler(MethodArgumentNotValidException.class)` - the obvious way to customise
validation errors - fails at startup ([`ambiguous-handler.txt`](output/ambiguous-handler.txt)):
```
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's `handleException` is `final` and already mapped. Override the protected
`handleMethodArgumentNotValid(...)` instead - [chapter 3](03-validation-errors.md).
## What the recommended setup is
`advice` + `errors`:
- [`GlobalExceptionHandler`](../src/main/java/com/ankurm/problems/advice/GlobalExceptionHandler.java)
extends `ResponseEntityExceptionHandler`, adds domain handlers and a logging catch-all
- [`ProblemDetailErrorController`](../src/main/java/com/ankurm/problems/advice/ProblemDetailErrorController.java)
covers the container error page - [chapter 6](06-outside-mvc.md)
- [`ProblemDetailSecurityHandlers`](../src/main/java/com/ankurm/problems/security/ProblemDetailSecurityHandlers.java)
covers 401/403 with a `WWW-Authenticate` header intact
[`AdviceContractTest`](../src/test/java/com/ankurm/problems/AdviceContractTest.java) pins it.
@@ -0,0 +1,62 @@
# 3. Validation errors that say something
[← 2. Choosing a mechanism](02-choosing-a-mechanism.md) · [Index](../README.md) · Next: [4. Types and message codes →](04-i18n-and-types.md)
Two exceptions, depending on where the constraint is:
| Constraint on | Exception | Stock `detail` |
|---|---|---|
| a `@Valid @RequestBody` object | `MethodArgumentNotValidException` | `Invalid request content.` |
| a parameter directly (`@RequestParam @Max(100) int limit`) | `HandlerMethodValidationException` | `Validation failure` |
The second is Spring Framework 6.1's built-in method validation: a constraint annotation on a
controller parameter is enough, no `@Validated` on the class.
## The stock body tells the client nothing
From [`matrix-boot-flag.txt`](output/matrix-boot-flag.txt) - three violations in the request, none in
the response:
```
HTTP 400 Content-Type: application/problem+json
{"detail":"Invalid request content.","instance":"/orders","status":400,"title":"Bad Request"}
```
## Adding the violations
Override the protected hook, keep the body the base class built, add an extension member, and hand
it back to `handleExceptionInternal` so headers and status stay right
([`GlobalExceptionHandler`](../src/main/java/com/ankurm/problems/advice/GlobalExceptionHandler.java)):
```java
@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);
}
```
The `{"pointer","detail"}` shape is not invented: it is the example RFC 9457 itself uses in
section 3. From [`matrix-advice.txt`](output/matrix-advice.txt):
```
{"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"}]}
```
## Do not assert on the order
The order in that line is not declaration order, and it differed between runs of the same
request. The contract
test asserts membership, not position.
## Nested fields
`FieldError.getField()` uses dots for nesting (`address.city`) and brackets for indexes
(`lines[0].sku`). The `replace('.', '/')` here handles the first; if your API has lists of objects,
convert `[n]` to `/n` as well so the pointer is a valid JSON Pointer.
+51
View File
@@ -0,0 +1,51 @@
# 4. Problem types, message codes and the `about:blank` change in 7.0
[← 3. Validation errors](03-validation-errors.md) · [Index](../README.md) · Next: [5. Content negotiation →](05-content-negotiation.md)
## `type` is for machines
`title` and `detail` are for people and may be translated. `type` is the one member a client should
branch on, so give each of your problem kinds a stable URI. It does not have to resolve - RFC 9457
section 3.1.1 covers non-dereferenceable URIs - but if it does, put documentation there.
## Spring Framework 7 no longer defaults it
[`type-default.txt`](output/type-default.txt), 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)` (read with `javap -v`),
so 6.2 rendered `"type":"about:blank"` on every framework error and 7.0 omits the member. RFC 9457
says an absent `type` *means* `about:blank`, so neither is wrong - but:
- a contract test or client that matched the literal `"type":"about:blank"` breaks on the upgrade
- on the client side `ProblemDetail.getType()` is now `null` for these responses, so
`pd.getType().equals(...)` throws ([chapter 8](08-clients.md))
## Message codes
`ResponseEntityExceptionHandler` resolves `title` and `detail` for any `ErrorResponse` through the
`MessageSource`, with these codes:
| Member | Code |
|---|---|
| `type` | `problemDetail.type.<fully qualified exception class>` |
| `title` | `problemDetail.title.<fully qualified exception class>` |
| `detail` | `problemDetail.<fully qualified exception class>` (+ optional suffix) |
Arguments for `detail` come from `getDetailMessageArguments()`.
[`OutOfStockException`](../src/main/java/com/ankurm/problems/domain/OutOfStockException.java)
returns `{sku, available}`, and [`messages.properties`](../src/main/resources/messages.properties)
uses them. Its constructor sets `title="Insufficient stock"`; the message source wins
([`i18n.txt`](output/i18n.txt)):
```
{"detail":"Only 0 unit(s) of SKU-2 are available.","instance":"/orders","status":409,"title":"Out of stock","type":"https://ankurm.com/problems/out-of-stock","sku":"SKU-2","available":0}
{"detail":"Von SKU-2 sind nur 0 Stück verfügbar.","instance":"/orders","status":409,"title":"Nicht vorrätig","type":"https://ankurm.com/problems/out-of-stock","sku":"SKU-2","available":0}
```
This only happens on the `ResponseEntityExceptionHandler` path. A `ProblemDetail` you build in your
own `@ExceptionHandler` method is rendered as built.
@@ -0,0 +1,27 @@
# 5. Content negotiation: why `Accept: application/xml` gets JSON
[← 4. Types and message codes](04-i18n-and-types.md) · [Index](../README.md) · Next: [6. Outside Spring MVC →](06-outside-mvc.md)
With `jackson-dataformat-xml` on the classpath a successful response honours `Accept:
application/xml`. An error response to the same request does not
([`content-negotiation.txt`](output/content-negotiation.txt)):
| `Accept` | `GET /orders/1` (200) | `GET /orders/999` (404) |
|---|---|---|
| `application/json` | `application/json` | `application/problem+json` |
| `application/xml` | `application/xml` | **`application/problem+json`** |
| `application/problem+xml` | - | `application/problem+xml` |
| `text/html` | - | `application/problem+json` |
| `image/png` | - | `application/problem+json` (still 404, not 406) |
For a `ProblemDetail` body the producible types are `application/problem+json` and
`application/problem+xml`. `application/xml` is compatible with neither - `problem+xml` is a
different subtype, not a specialisation - so negotiation finds no match and Spring falls back to
JSON rather than failing the error response with a 406. The fallback is the right call; the
surprise is that an XML client must ask for `application/problem+xml` by name to get XML errors.
The XML body uses the RFC's namespace, which RFC 9457 kept from 7807:
```
<problem xmlns="urn:ietf:rfc:7807"><detail>Order 999 does not exist</detail><instance>/orders/999</instance><status>404</status><title>Order not found</title><type>https://ankurm.com/problems/order-not-found</type><orderId>999</orderId></problem>
```
+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`.
+25
View File
@@ -0,0 +1,25 @@
# 7. Silent 500s: the catch-all that makes errors invisible
[← 6. Outside Spring MVC](06-outside-mvc.md) · [Index](../README.md) · Next: [8. Clients →](08-clients.md)
When an exception reaches the container, Tomcat logs it with its stack trace. When an
`@ExceptionHandler` handles it, **nothing logs it unless that handler does** - handling an
exception means it is no longer an error as far as the framework is concerned.
[`silent-500.txt`](output/silent-500.txt), one request to `/orders/boom` per setup:
```
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 tidy `500` problem and leave no trace anywhere. Adding problem details to an
API that used to log its failures can remove the only record of them.
[`GlobalExceptionHandler.unexpected`](../src/main/java/com/ankurm/problems/advice/GlobalExceptionHandler.java)
logs at ERROR with a generated `errorId`, puts the same id in the response, and keeps
`ex.getMessage()` out of it - the message in this project contains a JDBC URL and a database user,
which is exactly what RFC 9457's security considerations warn against exposing.
+42
View File
@@ -0,0 +1,42 @@
# 8. The client side: decoding problems with `RestClient`
[← 7. Silent 500s](07-silent-500s.md) · [Index](../README.md)
`RestClient`'s default status handler throws `HttpClientErrorException` /
`HttpServerErrorException`; `getResponseBodyAs(ProblemDetail.class)` decodes the body with the
client's converters. [`/diag/decode`](../src/main/java/com/ankurm/problems/diag/DiagController.java)
reports what came back ([`client-decoding.txt`](output/client-decoding.txt)).
## What works
Extension members land in `getProperties()` - `orderId`, the `errors` list, `errorId`. The mixin
works in both directions.
## Two things to code defensively against
**`getType()` is `null` when the server omitted it**, which Spring Framework 7 servers do for every
framework error ([chapter 4](04-i18n-and-types.md)). Treat `null` as `about:blank`.
**Decoding succeeds on a body that is not a problem at all.** Against the `defaults` profile the
server sends Boot's `application/json` error, and it still decodes:
```
"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": {
"timestamp": "2026-09-11T17:05:01.408Z",
"error": "Internal Server Error",
"path": "/orders/999"
}
}
```
Unknown members go into `properties`, so any JSON object "is" a `ProblemDetail`. Check the
`Content-Type` is `application/problem+json` before believing you have one.
@@ -0,0 +1,15 @@
## profiles: <none>
## profiles: boot-flag
order 0 org.springframework.boot.webmvc.autoconfigure.ProblemDetailsExceptionHandler
## profiles: advice
order 2147483647 com.ankurm.problems.advice.GlobalExceptionHandler
## profiles: boot-flag,advice
order 2147483647 com.ankurm.problems.advice.GlobalExceptionHandler
## profiles: catchall-first
order -2147483648 com.ankurm.problems.advice.CatchAllFirstHandler
order 0 org.springframework.boot.webmvc.autoconfigure.ProblemDetailsExceptionHandler
@@ -0,0 +1,5 @@
# Profile: ambiguous (exit code 1)
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}
@@ -0,0 +1,123 @@
# Profile: advice
## RestClient GET /orders/999 -> ex.getResponseBodyAs(ProblemDetail.class)
{
"exception": "org.springframework.web.client.HttpClientErrorException$NotFound",
"status": 404,
"contentType": "application/problem+json",
"problemDetail": {
"type": "https://ankurm.com/problems/order-not-found",
"title": "Order not found",
"status": 404,
"detail": "Order 999 does not exist",
"instance": "/orders/999",
"properties": {
"orderId": 999
}
}
}
## RestClient GET /orders?limit=500 -> ex.getResponseBodyAs(ProblemDetail.class)
{
"exception": "org.springframework.web.client.HttpClientErrorException$BadRequest",
"status": 400,
"contentType": "application/problem+json",
"problemDetail": {
"type": "null",
"title": "Bad Request",
"status": 400,
"detail": "Validation failure",
"instance": "/orders",
"properties": {
"errors": [
{
"parameter": "limit",
"detail": "must be less than or equal to 100"
}
]
}
}
}
## RestClient GET /orders/boom -> ex.getResponseBodyAs(ProblemDetail.class)
{
"exception": "org.springframework.web.client.HttpServerErrorException$InternalServerError",
"status": 500,
"contentType": "application/problem+json",
"problemDetail": {
"type": "null",
"title": "Internal Server Error",
"status": 500,
"detail": "An unexpected error occurred. Quote errorId when reporting it.",
"instance": "/orders/boom",
"properties": {
"errorId": "d09000a5-d6bf-438d-a1a0-527d7cd9d6b7"
}
}
}
# Profile: defaults
## RestClient GET /orders/999 -> ex.getResponseBodyAs(ProblemDetail.class)
{
"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": {
"timestamp": "2026-09-11T17:05:01.408Z",
"error": "Internal Server Error",
"path": "/orders/999"
}
}
}
## RestClient GET /orders?limit=500 -> ex.getResponseBodyAs(ProblemDetail.class)
{
"exception": "org.springframework.web.client.HttpClientErrorException$BadRequest",
"status": 400,
"contentType": "application/json",
"problemDetail": {
"type": "null",
"title": "Bad Request",
"status": 400,
"detail": null,
"instance": "null",
"properties": {
"timestamp": "2026-09-11T17:05:01.620Z",
"error": "Bad Request",
"path": "/orders"
}
}
}
## RestClient GET /orders/boom -> ex.getResponseBodyAs(ProblemDetail.class)
{
"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": {
"timestamp": "2026-09-11T17:05:01.660Z",
"error": "Internal Server Error",
"path": "/orders/boom"
}
}
}
# ProblemDetail with one extension member, serialised three ways
{
"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}"
}
@@ -0,0 +1,44 @@
# Accept header vs error body (profile: advice, jackson-dataformat-xml on the classpath)
## A successful response, for comparison
$ curl -H "Accept: application/xml" /orders/1
HTTP/1.1 200
Content-Type: application/xml;charset=UTF-8
<Order><id>1</id><sku>SKU-1</sku><quantity>2</quantity></Order>
## Accept: application/json
$ curl -H "Accept: application/json" /orders/999
HTTP/1.1 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}
## Accept: application/xml
$ curl -H "Accept: application/xml" /orders/999
HTTP/1.1 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}
## Accept: application/problem+xml
$ curl -H "Accept: application/problem+xml" /orders/999
HTTP/1.1 404
Content-Type: application/problem+xml
<problem xmlns="urn:ietf:rfc:7807"><detail>Order 999 does not exist</detail><instance>/orders/999</instance><status>404</status><title>Order not found</title><type>https://ankurm.com/problems/order-not-found</type><orderId>999</orderId></problem>
## Accept: text/html
$ curl -H "Accept: text/html" /orders/999
HTTP/1.1 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}
## Accept: image/png
$ curl -H "Accept: image/png" /orders/999
HTTP/1.1 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}
## Accept: */*
$ curl -H "Accept: */*" /orders/999
HTTP/1.1 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}
@@ -0,0 +1,18 @@
# Profile: errors (ProblemDetailErrorController only - no @ControllerAdvice, no Security handlers)
$ 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"}
$ curl -u user:user /admin/orders
HTTP/1.1 403
Content-Type: application/problem+json
{"detail":"Forbidden","instance":"/admin/orders","status":403,"title":"Forbidden"}
$ curl /orders/999
HTTP/1.1 500
Content-Type: application/problem+json
{"detail":"An unexpected error occurred.","instance":"/orders/999","status":500,"title":"Internal Server Error"}
+13
View File
@@ -0,0 +1,13 @@
# OutOfStockException - its own ProblemDetail vs messages.properties vs messages_de.properties
## Exception's own text (what the constructor set):
title="Insufficient stock" detail="Requested 3 of SKU-2 but only 0 available"
## Accept-Language: <none>
$ curl -X POST /orders -d '{"sku":"SKU-2","quantity":3,"customerEmail":"[email protected]"}'
{"detail":"Only 0 unit(s) of SKU-2 are available.","instance":"/orders","status":409,"title":"Out of stock","type":"https://ankurm.com/problems/out-of-stock","sku":"SKU-2","available":0}
## Accept-Language: de
$ curl -X POST /orders -d '{"sku":"SKU-2","quantity":3,"customerEmail":"[email protected]"}' -H 'Accept-Language: de'
{"detail":"Von SKU-2 sind nur 0 Stück verfügbar.","instance":"/orders","status":409,"title":"Nicht vorrätig","type":"https://ankurm.com/problems/out-of-stock","sku":"SKU-2","available":0}
@@ -0,0 +1,67 @@
# Profile: advice,errors (spring-boot 4.1.1, spring-framework 7.0.9)
## domain exception (OrderNotFoundException)
$ 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}
## type mismatch (/orders/abc)
$ curl -X GET /orders/abc
HTTP 400 Content-Type: application/problem+json
{"detail":"Failed to convert 'id' with value: 'abc'","instance":"/orders/abc","status":400,"title":"Bad Request"}
## invalid body (@Valid)
$ curl -X POST /orders -d '{"sku":"","quantity":0,"customerEmail":"nope"}'
HTTP 400 Content-Type: application/problem+json
{"detail":"The request body has 3 invalid field(s)","instance":"/orders","status":400,"title":"Bad Request","errors":[{"detail":"must not be blank","pointer":"#/sku"},{"detail":"must be a well-formed email address","pointer":"#/customerEmail"},{"detail":"must be greater than or equal to 1","pointer":"#/quantity"}]}
## invalid @RequestParam (@Max)
$ curl -X GET /orders?limit=500
HTTP 400 Content-Type: application/problem+json
{"detail":"Validation failure","instance":"/orders","status":400,"title":"Bad Request","errors":[{"detail":"must be less than or equal to 100","parameter":"limit"}]}
## ErrorResponseException (OutOfStock)
$ curl -X POST /orders -d '{"sku":"SKU-2","quantity":3,"customerEmail":"[email protected]"}'
HTTP 409 Content-Type: application/problem+json
{"detail":"Only 0 unit(s) of SKU-2 are available.","instance":"/orders","status":409,"title":"Out of stock","type":"https://ankurm.com/problems/out-of-stock","sku":"SKU-2","available":0}
## ResponseStatusException
$ curl -X GET /orders/legacy/7
HTTP 410 Content-Type: application/problem+json
{"detail":"Legacy order ids were retired in 2024","instance":"/orders/legacy/7","status":410,"title":"Gone"}
## unknown path
$ curl -X GET /no-such-thing
HTTP 404 Content-Type: application/problem+json
{"detail":"No static resource no-such-thing.","instance":"/no-such-thing","status":404,"title":"Not Found"}
## wrong HTTP method
$ 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"}
## malformed JSON
$ curl -X POST /orders -d '{"sku":'
HTTP 400 Content-Type: application/problem+json
{"detail":"Failed to read request","instance":"/orders","status":400,"title":"Bad Request"}
## unexpected exception
$ curl -X GET /orders/boom
HTTP 500 Content-Type: application/problem+json
{"detail":"An unexpected error occurred. Quote errorId when reporting it.","instance":"/orders/boom","status":500,"title":"Internal Server Error","errorId":"a3838ab0-afd2-4bbd-85b5-15e9a309c1f8"}
## exception in a servlet filter
$ 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"}
## 401 no credentials
$ curl -X GET /admin/orders
HTTP 401 Content-Type: application/problem+json
{"detail":"Authentication is required to access this resource","instance":"/admin/orders","status":401,"title":"Unauthorized","type":"https://ankurm.com/problems/authentication-required","scheme":"Basic"}
## 403 wrong role
$ curl -X GET /admin/orders [AUTH:user:user]
HTTP 403 Content-Type: application/problem+json
{"detail":"Your credentials do not grant access to this resource","instance":"/admin/orders","status":403,"title":"Forbidden","type":"https://ankurm.com/problems/access-denied"}
@@ -0,0 +1,67 @@
# Profile: advice (spring-boot 4.1.1, spring-framework 7.0.9)
## domain exception (OrderNotFoundException)
$ 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}
## type mismatch (/orders/abc)
$ curl -X GET /orders/abc
HTTP 400 Content-Type: application/problem+json
{"detail":"Failed to convert 'id' with value: 'abc'","instance":"/orders/abc","status":400,"title":"Bad Request"}
## invalid body (@Valid)
$ curl -X POST /orders -d '{"sku":"","quantity":0,"customerEmail":"nope"}'
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"}]}
## invalid @RequestParam (@Max)
$ curl -X GET /orders?limit=500
HTTP 400 Content-Type: application/problem+json
{"detail":"Validation failure","instance":"/orders","status":400,"title":"Bad Request","errors":[{"parameter":"limit","detail":"must be less than or equal to 100"}]}
## ErrorResponseException (OutOfStock)
$ curl -X POST /orders -d '{"sku":"SKU-2","quantity":3,"customerEmail":"[email protected]"}'
HTTP 409 Content-Type: application/problem+json
{"detail":"Only 0 unit(s) of SKU-2 are available.","instance":"/orders","status":409,"title":"Out of stock","type":"https://ankurm.com/problems/out-of-stock","sku":"SKU-2","available":0}
## ResponseStatusException
$ curl -X GET /orders/legacy/7
HTTP 410 Content-Type: application/problem+json
{"detail":"Legacy order ids were retired in 2024","instance":"/orders/legacy/7","status":410,"title":"Gone"}
## unknown path
$ curl -X GET /no-such-thing
HTTP 404 Content-Type: application/problem+json
{"detail":"No static resource no-such-thing.","instance":"/no-such-thing","status":404,"title":"Not Found"}
## wrong HTTP method
$ 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"}
## malformed JSON
$ curl -X POST /orders -d '{"sku":'
HTTP 400 Content-Type: application/problem+json
{"detail":"Failed to read request","instance":"/orders","status":400,"title":"Bad Request"}
## unexpected exception
$ curl -X GET /orders/boom
HTTP 500 Content-Type: application/problem+json
{"detail":"An unexpected error occurred. Quote errorId when reporting it.","instance":"/orders/boom","status":500,"title":"Internal Server Error","errorId":"ac0a11a2-1ef5-4ef2-828f-21107e0b9008"}
## exception in a servlet filter
$ curl -X GET /orders/1 [X-Tenant: BAD!]
HTTP 500 Content-Type: application/json
{"timestamp":"2026-09-11T17:03:21.460Z","status":500,"error":"Internal Server Error","path":"/orders/1"}
## 401 no credentials
$ curl -X GET /admin/orders
HTTP 401 Content-Type: application/problem+json
{"detail":"Authentication is required to access this resource","instance":"/admin/orders","status":401,"title":"Unauthorized","type":"https://ankurm.com/problems/authentication-required","scheme":"Basic"}
## 403 wrong role
$ curl -X GET /admin/orders [AUTH:user:user]
HTTP 403 Content-Type: application/problem+json
{"detail":"Your credentials do not grant access to this resource","instance":"/admin/orders","status":403,"title":"Forbidden","type":"https://ankurm.com/problems/access-denied"}
@@ -0,0 +1,67 @@
# Profile: boot-flag (spring-boot 4.1.1, spring-framework 7.0.9)
## domain exception (OrderNotFoundException)
$ curl -X GET /orders/999
HTTP 500 Content-Type: application/json
{"timestamp":"2026-09-11T17:03:12.637Z","status":500,"error":"Internal Server Error","path":"/orders/999"}
## type mismatch (/orders/abc)
$ curl -X GET /orders/abc
HTTP 400 Content-Type: application/problem+json
{"detail":"Failed to convert 'id' with value: 'abc'","instance":"/orders/abc","status":400,"title":"Bad Request"}
## invalid body (@Valid)
$ 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"}
## invalid @RequestParam (@Max)
$ curl -X GET /orders?limit=500
HTTP 400 Content-Type: application/problem+json
{"detail":"Validation failure","instance":"/orders","status":400,"title":"Bad Request"}
## ErrorResponseException (OutOfStock)
$ curl -X POST /orders -d '{"sku":"SKU-2","quantity":3,"customerEmail":"[email protected]"}'
HTTP 409 Content-Type: application/problem+json
{"detail":"Only 0 unit(s) of SKU-2 are available.","instance":"/orders","status":409,"title":"Out of stock","type":"https://ankurm.com/problems/out-of-stock","sku":"SKU-2","available":0}
## ResponseStatusException
$ curl -X GET /orders/legacy/7
HTTP 410 Content-Type: application/problem+json
{"detail":"Legacy order ids were retired in 2024","instance":"/orders/legacy/7","status":410,"title":"Gone"}
## unknown path
$ curl -X GET /no-such-thing
HTTP 404 Content-Type: application/problem+json
{"detail":"No static resource no-such-thing.","instance":"/no-such-thing","status":404,"title":"Not Found"}
## wrong HTTP method
$ 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"}
## malformed JSON
$ curl -X POST /orders -d '{"sku":'
HTTP 400 Content-Type: application/problem+json
{"detail":"Failed to read request","instance":"/orders","status":400,"title":"Bad Request"}
## unexpected exception
$ curl -X GET /orders/boom
HTTP 500 Content-Type: application/json
{"timestamp":"2026-09-11T17:03:13.097Z","status":500,"error":"Internal Server Error","path":"/orders/boom"}
## exception in a servlet filter
$ curl -X GET /orders/1 [X-Tenant: BAD!]
HTTP 500 Content-Type: application/json
{"timestamp":"2026-09-11T17:03:13.125Z","status":500,"error":"Internal Server Error","path":"/orders/1"}
## 401 no credentials
$ curl -X GET /admin/orders
HTTP 401 Content-Type: application/json
{"timestamp":"2026-09-11T17:03:13.161Z","status":401,"error":"Unauthorized","path":"/admin/orders"}
## 403 wrong role
$ curl -X GET /admin/orders [AUTH:user:user]
HTTP 403 Content-Type: application/json
{"timestamp":"2026-09-11T17:03:13.399Z","status":403,"error":"Forbidden","path":"/admin/orders"}
@@ -0,0 +1,67 @@
# Profile: catchall-first (spring-boot 4.1.1, spring-framework 7.0.9)
## domain exception (OrderNotFoundException)
$ curl -X GET /orders/999
HTTP 500 Content-Type: application/problem+json
{"detail":"Something went wrong","instance":"/orders/999","status":500,"title":"Internal Server Error"}
## type mismatch (/orders/abc)
$ curl -X GET /orders/abc
HTTP 500 Content-Type: application/problem+json
{"detail":"Something went wrong","instance":"/orders/abc","status":500,"title":"Internal Server Error"}
## invalid body (@Valid)
$ curl -X POST /orders -d '{"sku":"","quantity":0,"customerEmail":"nope"}'
HTTP 500 Content-Type: application/problem+json
{"detail":"Something went wrong","instance":"/orders","status":500,"title":"Internal Server Error"}
## invalid @RequestParam (@Max)
$ curl -X GET /orders?limit=500
HTTP 500 Content-Type: application/problem+json
{"detail":"Something went wrong","instance":"/orders","status":500,"title":"Internal Server Error"}
## ErrorResponseException (OutOfStock)
$ curl -X POST /orders -d '{"sku":"SKU-2","quantity":3,"customerEmail":"[email protected]"}'
HTTP 500 Content-Type: application/problem+json
{"detail":"Something went wrong","instance":"/orders","status":500,"title":"Internal Server Error"}
## ResponseStatusException
$ curl -X GET /orders/legacy/7
HTTP 500 Content-Type: application/problem+json
{"detail":"Something went wrong","instance":"/orders/legacy/7","status":500,"title":"Internal Server Error"}
## unknown path
$ 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"}
## wrong HTTP method
$ curl -X DELETE /orders/1
HTTP 500 Content-Type: application/problem+json
{"detail":"Something went wrong","instance":"/orders/1","status":500,"title":"Internal Server Error"}
## malformed JSON
$ curl -X POST /orders -d '{"sku":'
HTTP 500 Content-Type: application/problem+json
{"detail":"Something went wrong","instance":"/orders","status":500,"title":"Internal Server Error"}
## unexpected exception
$ curl -X GET /orders/boom
HTTP 500 Content-Type: application/problem+json
{"detail":"Something went wrong","instance":"/orders/boom","status":500,"title":"Internal Server Error"}
## exception in a servlet filter
$ curl -X GET /orders/1 [X-Tenant: BAD!]
HTTP 500 Content-Type: application/json
{"timestamp":"2026-09-11T17:03:37.596Z","status":500,"error":"Internal Server Error","path":"/orders/1"}
## 401 no credentials
$ curl -X GET /admin/orders
HTTP 401 Content-Type: application/json
{"timestamp":"2026-09-11T17:03:37.638Z","status":401,"error":"Unauthorized","path":"/admin/orders"}
## 403 wrong role
$ curl -X GET /admin/orders [AUTH:user:user]
HTTP 403 Content-Type: application/json
{"timestamp":"2026-09-11T17:03:37.845Z","status":403,"error":"Forbidden","path":"/admin/orders"}
@@ -0,0 +1,67 @@
# Profile: defaults (spring-boot 4.1.1, spring-framework 7.0.9)
## domain exception (OrderNotFoundException)
$ curl -X GET /orders/999
HTTP 500 Content-Type: application/json
{"timestamp":"2026-09-11T17:03:04.029Z","status":500,"error":"Internal Server Error","path":"/orders/999"}
## type mismatch (/orders/abc)
$ curl -X GET /orders/abc
HTTP 400 Content-Type: application/json
{"timestamp":"2026-09-11T17:03:04.063Z","status":400,"error":"Bad Request","path":"/orders/abc"}
## invalid body (@Valid)
$ curl -X POST /orders -d '{"sku":"","quantity":0,"customerEmail":"nope"}'
HTTP 400 Content-Type: application/json
{"timestamp":"2026-09-11T17:03:04.250Z","status":400,"error":"Bad Request","path":"/orders"}
## invalid @RequestParam (@Max)
$ curl -X GET /orders?limit=500
HTTP 400 Content-Type: application/json
{"timestamp":"2026-09-11T17:03:04.301Z","status":400,"error":"Bad Request","path":"/orders"}
## ErrorResponseException (OutOfStock)
$ curl -X POST /orders -d '{"sku":"SKU-2","quantity":3,"customerEmail":"[email protected]"}'
HTTP 409 Content-Type: application/json
{"timestamp":"2026-09-11T17:03:04.354Z","status":409,"error":"Conflict","path":"/orders"}
## ResponseStatusException
$ curl -X GET /orders/legacy/7
HTTP 410 Content-Type: application/json
{"timestamp":"2026-09-11T17:03:04.383Z","status":410,"error":"Gone","path":"/orders/legacy/7"}
## unknown path
$ curl -X GET /no-such-thing
HTTP 404 Content-Type: application/json
{"timestamp":"2026-09-11T17:03:04.412Z","status":404,"error":"Not Found","path":"/no-such-thing"}
## wrong HTTP method
$ curl -X DELETE /orders/1
HTTP 405 Content-Type: application/json
{"timestamp":"2026-09-11T17:03:04.437Z","status":405,"error":"Method Not Allowed","path":"/orders/1"}
## malformed JSON
$ curl -X POST /orders -d '{"sku":'
HTTP 400 Content-Type: application/json
{"timestamp":"2026-09-11T17:03:04.462Z","status":400,"error":"Bad Request","path":"/orders"}
## unexpected exception
$ curl -X GET /orders/boom
HTTP 500 Content-Type: application/json
{"timestamp":"2026-09-11T17:03:04.487Z","status":500,"error":"Internal Server Error","path":"/orders/boom"}
## exception in a servlet filter
$ curl -X GET /orders/1 [X-Tenant: BAD!]
HTTP 500 Content-Type: application/json
{"timestamp":"2026-09-11T17:03:04.509Z","status":500,"error":"Internal Server Error","path":"/orders/1"}
## 401 no credentials
$ curl -X GET /admin/orders
HTTP 401 Content-Type: application/json
{"timestamp":"2026-09-11T17:03:04.540Z","status":401,"error":"Unauthorized","path":"/admin/orders"}
## 403 wrong role
$ curl -X GET /admin/orders [AUTH:user:user]
HTTP 403 Content-Type: application/json
{"timestamp":"2026-09-11T17:03:04.735Z","status":403,"error":"Forbidden","path":"/admin/orders"}
@@ -0,0 +1,18 @@
# Which error shape does each failure produce? (status + body shape)
# Spring Boot 4.1.1 / Spring Framework 7.0.9. Full bodies in matrix-<profile>.txt
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
@@ -0,0 +1,15 @@
# One request to an endpoint that throws IllegalStateException. What reaches the log?
defaults (Boot /error) ERROR lines: 1 stack frames: 87
ERROR 30257 --- [problem-details] [omcat-handler-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request pr
boot-flag ERROR lines: 1 stack frames: 87
ERROR 30389 --- [problem-details] [omcat-handler-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request pr
advice, catch-all logs with errorId ERROR lines: 1 stack frames: 87
ERROR 30521 --- [problem-details] [omcat-handler-1] c.a.p.advice.GlobalExceptionHandler : Unhandled exception, errorId=0becc142-9239-44ba-93ae-c0d479052095
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
# And for an exception thrown by a servlet filter (never reaches any advice):
advice,errors - filter exception ERROR lines: 1 stack frames: 68
ERROR 30882 --- [problem-details] [omcat-handler-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception
@@ -0,0 +1,9 @@
# ProblemDetail.forStatus(404).getType(), evaluated in jshell against each spring-web jar
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
# "type":"about:blank" and 7.0 omits the member. As rendered by this application (7.0.9):
$ curl /no-such-thing
{"detail":"No static resource no-such-thing.","instance":"/no-such-thing","status":404,"title":"Not Found"}