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:
@@ -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.
|
||||
@@ -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>
|
||||
```
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user