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