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 @@
#!/usr/bin/env bash
# Which @ControllerAdvice beans exist, in consultation order. -> docs/output/advice-order.txt
set -uo pipefail
source "$(dirname "$0")/env.sh"
{
for profile in "" boot-flag advice "boot-flag,advice" catchall-first; do
"$MODULE_DIR/scripts/run.sh" "$profile" || exit 1
echo "## profiles: ${profile:-<none>}"
curl -s "$BASE/diag/advice" | python3 -c 'import json,sys
for r in json.load(sys.stdin): print(" order %-12s %s" % (r["order"], r["bean"]))
' ; echo
done
} > "$OUT/advice-order.txt"
"$MODULE_DIR/scripts/stop.sh"
cat "$OUT/advice-order.txt"
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Declaring @ExceptionHandler(MethodArgumentNotValidException.class) in a subclass of
# ResponseEntityExceptionHandler. -> docs/output/ambiguous-handler.txt
set -uo pipefail
source "$(dirname "$0")/env.sh"
"$MODULE_DIR/scripts/stop.sh"
timeout 60 java -jar "$JAR" --spring.profiles.active=ambiguous --server.port="$PORT" > "$LOG" 2>&1
code=$?
{
echo "# Profile: ambiguous (exit code $code)"
echo
grep -v 'Picked up JAVA_TOOL_OPTIONS' "$LOG" | grep -E '^Caused by: java.lang.IllegalStateException: Ambiguous' | head -1 \
| sed -e 's/: {/:\n {/' -e 's/, public/,\n public/'
} > "$OUT/ambiguous-handler.txt"
cat "$OUT/ambiguous-handler.txt"
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# What a RestClient caller can decode from each error body. -> docs/output/client-decoding.txt
set -uo pipefail
source "$(dirname "$0")/env.sh"
{
for profile in advice ""; do
"$MODULE_DIR/scripts/run.sh" "$profile" || exit 1
echo "# Profile: ${profile:-defaults}"
echo
for path in /orders/999 '/orders?limit=500' /orders/boom; do
echo "## RestClient GET $path -> ex.getResponseBodyAs(ProblemDetail.class)"
curl -s -G "$BASE/diag/decode" --data-urlencode "path=$path" | python3 -m json.tool
echo
done
done
echo "# ProblemDetail with one extension member, serialised three ways"
"$MODULE_DIR/scripts/run.sh" advice || exit 1
curl -s "$BASE/diag/mixin" | python3 -m json.tool
} > "$OUT/client-decoding.txt"
"$MODULE_DIR/scripts/stop.sh"
cat "$OUT/client-decoding.txt"
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# The error controller on its own (profile errors, no advice): Security's sendError() responses
# land on /error too, so they become problems without custom Security handlers.
# -> docs/output/errors-only.txt
set -uo pipefail
source "$(dirname "$0")/env.sh"
"$MODULE_DIR/scripts/run.sh" errors || exit 1
{
echo "# Profile: errors (ProblemDetailErrorController only - no @ControllerAdvice, no Security handlers)"
echo
for args in "/admin/orders" "-u user:user /admin/orders" "/orders/999"; do
echo "\$ curl $args"
curl -s -i $(echo "$args" | sed "s#/#$BASE/#") | grep -iE '^HTTP|^content-type|^www-authenticate|^\{' | tr -d '\r'
echo
done
} > "$OUT/errors-only.txt"
"$MODULE_DIR/scripts/stop.sh"
cat "$OUT/errors-only.txt"
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# title/detail resolved from messages*.properties for an ErrorResponseException. -> docs/output/i18n.txt
set -uo pipefail
source "$(dirname "$0")/env.sh"
"$MODULE_DIR/scripts/run.sh" advice || exit 1
BODY='{"sku":"SKU-2","quantity":3,"customerEmail":"[email protected]"}'
{
echo "# OutOfStockException - its own ProblemDetail vs messages.properties vs messages_de.properties"
echo
echo "## Exception's own text (what the constructor set):"
echo " title=\"Insufficient stock\" detail=\"Requested 3 of SKU-2 but only 0 available\""
echo
for lang in '' 'de'; do
echo "## Accept-Language: ${lang:-<none>}"
echo "\$ curl -X POST /orders -d '$BODY'${lang:+ -H 'Accept-Language: $lang'}"
curl -s -X POST "$BASE/orders" -H 'Content-Type: application/json' ${lang:+-H "Accept-Language: $lang"} --data "$BODY"; echo; echo
done
} > "$OUT/i18n.txt"
"$MODULE_DIR/scripts/stop.sh"
cat "$OUT/i18n.txt"
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# The article's first table: the same thirteen failures under each exception-handling setup.
# Writes docs/output/matrix-<profile>.txt (full responses) and docs/output/matrix-summary.txt.
set -uo pipefail
source "$(dirname "$0")/env.sh"
SCENARIOS=(
"domain exception (OrderNotFoundException)|GET|/orders/999||"
"type mismatch (/orders/abc)|GET|/orders/abc||"
"invalid body (@Valid)|POST|/orders|{\"sku\":\"\",\"quantity\":0,\"customerEmail\":\"nope\"}|"
"invalid @RequestParam (@Max)|GET|/orders?limit=500||"
"ErrorResponseException (OutOfStock)|POST|/orders|{\"sku\":\"SKU-2\",\"quantity\":3,\"customerEmail\":\"[email protected]\"}|"
"ResponseStatusException|GET|/orders/legacy/7||"
"unknown path|GET|/no-such-thing||"
"wrong HTTP method|DELETE|/orders/1||"
"malformed JSON|POST|/orders|{\"sku\":|"
"unexpected exception|GET|/orders/boom||"
"exception in a servlet filter|GET|/orders/1||X-Tenant: BAD!"
"401 no credentials|GET|/admin/orders||"
"403 wrong role|GET|/admin/orders||AUTH:user:user"
)
classify() { # content-type, body -> shape label
local ct="$1" body="$2"
if [[ "$ct" == *problem+json* ]]; then echo "problem+json"
elif [[ -z "$body" ]]; then echo "(empty body)"
elif [[ "$body" == *'"timestamp"'* && "$body" == *'"error"'* ]]; then echo "Boot /error JSON"
else echo "other: ${ct:-none}"; fi
}
SUMMARY="$OUT/matrix-summary.txt"
PROFILES=("" "boot-flag" "advice" "advice,errors" "catchall-first")
declare -A CELL
for profile in "${PROFILES[@]}"; do
name="${profile:-defaults}"
"$MODULE_DIR/scripts/run.sh" "$profile" || exit 1
file="$OUT/matrix-$name.txt"
{
echo "# Profile: $name (spring-boot 4.1.1, spring-framework 7.0.9)"
echo
} > "$file"
i=0
for s in "${SCENARIOS[@]}"; do
IFS='|' read -r label method path body extra <<< "$s"
args=(-s -o /tmp/pd-body -D /tmp/pd-headers -X "$method" "$BASE$path")
[ -n "$body" ] && args+=(-H 'Content-Type: application/json' --data "$body")
if [[ "$extra" == AUTH:* ]]; then args+=(-u "${extra#AUTH:}");
elif [ -n "$extra" ]; then args+=(-H "$extra"); fi
curl "${args[@]}"
status=$(head -1 /tmp/pd-headers | awk '{print $2}')
ct=$(grep -i '^content-type:' /tmp/pd-headers | head -1 | cut -d' ' -f2- | tr -d '\r')
resp=$(cat /tmp/pd-body)
{
echo "## $label"
echo "\$ curl -X $method $path${body:+ -d '$body'}${extra:+ [$extra]}"
echo "HTTP $status Content-Type: ${ct:-<none>}"
[ -n "$resp" ] && echo "$resp"
echo
} >> "$file"
CELL["$i|$name"]="$status $(classify "$ct" "$resp")"
i=$((i+1))
done
done
"$MODULE_DIR/scripts/stop.sh"
{
echo "# Which error shape does each failure produce? (status + body shape)"
echo "# Spring Boot 4.1.1 / Spring Framework 7.0.9. Full bodies in matrix-<profile>.txt"
echo
printf '%-42s | %-22s | %-22s | %-22s | %-22s | %-22s\n' "failure" "defaults" "boot-flag" "advice" "advice,errors" "catchall-first"
printf '%s\n' "$(printf -- '-%.0s' {1..165})"
i=0
for s in "${SCENARIOS[@]}"; do
label="${s%%|*}"
printf '%-42s | %-22s | %-22s | %-22s | %-22s | %-22s\n' "$label" "${CELL["$i|defaults"]}" "${CELL["$i|boot-flag"]}" "${CELL["$i|advice"]}" "${CELL["$i|advice,errors"]}" "${CELL["$i|catchall-first"]}"
i=$((i+1))
done
} > "$SUMMARY"
cat "$SUMMARY"
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# What Accept header gets which error body. Profile: advice. -> docs/output/content-negotiation.txt
set -uo pipefail
source "$(dirname "$0")/env.sh"
"$MODULE_DIR/scripts/run.sh" advice || exit 1
{
echo "# Accept header vs error body (profile: advice, jackson-dataformat-xml on the classpath)"
echo
echo "## A successful response, for comparison"
echo '$ curl -H "Accept: application/xml" /orders/1'
curl -s -i -H 'Accept: application/xml' "$BASE/orders/1" | grep -iE '^HTTP|^content-type' | tr -d '\r'
curl -s -H 'Accept: application/xml' "$BASE/orders/1"; echo; echo
for a in 'application/json' 'application/xml' 'application/problem+xml' 'text/html' 'image/png' '*/*'; do
echo "## Accept: $a"
echo "\$ curl -H \"Accept: $a\" /orders/999"
curl -s -i -H "Accept: $a" "$BASE/orders/999" | grep -iE '^HTTP|^content-type' | tr -d '\r'
curl -s -H "Accept: $a" "$BASE/orders/999"; echo; echo
done
} > "$OUT/content-negotiation.txt"
"$MODULE_DIR/scripts/stop.sh"
cat "$OUT/content-negotiation.txt"
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Is the unexpected exception LOGGED? Counts log lines mentioning it after one request.
# -> docs/output/silent-500.txt
set -uo pipefail
source "$(dirname "$0")/env.sh"
probe() { # label, profiles, extra args, path, header
local label="$1" profiles="$2" extra="$3" path="$4" header="${5:-}"
EXTRA_ARGS="$extra" "$MODULE_DIR/scripts/run.sh" "$profiles" || exit 1
local before; before=$(wc -l < "$LOG")
curl -s -o /dev/null ${header:+-H "$header"} "$BASE$path"
sleep 1
local lines; lines=$(tail -n +"$((before+1))" "$LOG" | grep -v 'Picked up JAVA_TOOL_OPTIONS')
local errors; errors=$(echo "$lines" | grep -c ' ERROR ' || true)
local traces; traces=$(echo "$lines" | grep -c '^ at ' || true)
printf '%-58s ERROR lines: %-3s stack frames: %s\n' "$label" "$errors" "$traces"
echo "$lines" | grep ' ERROR ' | sed 's/^.* ERROR / ERROR /' | cut -c1-200 | head -2
}
{
echo "# One request to an endpoint that throws IllegalStateException. What reaches the log?"
echo
probe "defaults (Boot /error)" "" "" /orders/boom
probe "boot-flag" "boot-flag" "" /orders/boom
probe "advice, catch-all logs with errorId" "advice" "" /orders/boom
probe "advice, catch-all WITHOUT the log line" "advice" "--demo.problems.log-unhandled=false" /orders/boom
probe "catchall-first (returns 500, never logs)" "catchall-first" "" /orders/boom
echo
echo "# And for an exception thrown by a servlet filter (never reaches any advice):"
echo
probe "advice,errors - filter exception" "advice,errors" "" /orders/1 "X-Tenant: BAD!"
} > "$OUT/silent-500.txt"
"$MODULE_DIR/scripts/stop.sh"
cat "$OUT/silent-500.txt"
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# ProblemDetail's default "type" in Spring Framework 6.2 vs 7.0, read straight from the jars.
# Needs spring-web 6.2.x and 7.0.x jars in the local Maven repository (mvn dependency:get
# -Dartifact=org.springframework:spring-web:6.2.19 fetches the old one).
# -> docs/output/type-default.txt
set -uo pipefail
source "$(dirname "$0")/env.sh"
M2="${M2:-$HOME/.m2/repository}"
CORE="$M2/org/springframework/spring-core/7.0.9/spring-core-7.0.9.jar:$(ls "$M2"/org/jspecify/jspecify/*/jspecify-*.jar | head -1)"
{
echo "# ProblemDetail.forStatus(404).getType(), evaluated in jshell against each spring-web jar"
echo
for W in "$M2/org/springframework/spring-web/6.2.19/spring-web-6.2.19.jar" \
"$M2/org/springframework/spring-web/7.0.9/spring-web-7.0.9.jar"; do
JSH="$(mktemp --suffix=.jsh)"
printf 'System.out.println("%s -> getType() = " + org.springframework.http.ProblemDetail.forStatus(404).getType());\n/exit\n' "$(basename "$W")" > "$JSH"
jshell --class-path "$W:$CORE" -q "$JSH" 2>&1 | grep -- '->'
rm -f "$JSH"
done
echo
echo "# Both versions' ProblemDetailJacksonMixin carry @JsonInclude(NON_EMPTY), so 6.2 rendered"
echo "# \"type\":\"about:blank\" and 7.0 omits the member. As rendered by this application (7.0.9):"
"$MODULE_DIR/scripts/run.sh" boot-flag >/dev/null || exit 1
echo '$ curl /no-such-thing'
curl -s "$BASE/no-such-thing"; echo
"$MODULE_DIR/scripts/stop.sh"
} > "$OUT/type-default.txt"
cat "$OUT/type-default.txt"
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Shared settings for every script in this module.
MODULE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OUT="$MODULE_DIR/docs/output"
JAR="$MODULE_DIR/target/problem-details-1.0.0.jar"
PORT="${PORT:-8080}"
BASE="http://localhost:$PORT"
PIDFILE="$MODULE_DIR/target/app.pid"
LOG="$MODULE_DIR/target/app.log"
mkdir -p "$OUT"
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Regenerate every transcript under docs/output/. Takes about two minutes.
set -euo pipefail
cd "$(dirname "$0")/.."
mvn -q -DskipTests package
./scripts/demo-matrix.sh
./scripts/demo-negotiation.sh
./scripts/demo-i18n.sh
./scripts/demo-silent-500.sh
./scripts/demo-ambiguous.sh
./scripts/demo-client.sh
./scripts/demo-advice-order.sh
./scripts/demo-type-default.sh
./scripts/demo-errors-only.sh
./scripts/stop.sh
echo "Regenerated: $(ls docs/output | wc -l) files in docs/output/"
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Start the application with the given profiles (comma-separated, may be empty) and wait until it
# answers. Any instance started by a previous run is stopped first, by PID file - never by
# pattern-matching the process list, which can match (and kill) the calling shell.
# ./scripts/run.sh advice
# ./scripts/run.sh "" # Spring Boot defaults
set -euo pipefail
source "$(dirname "$0")/env.sh"
"$MODULE_DIR/scripts/stop.sh"
PROFILES="${1:-}"
[ -f "$JAR" ] || (cd "$MODULE_DIR" && mvn -q -DskipTests package)
nohup java -jar "$JAR" --server.port="$PORT" ${PROFILES:+--spring.profiles.active=$PROFILES} ${EXTRA_ARGS:-} \
> "$LOG" 2>&1 < /dev/null &
echo $! > "$PIDFILE"
for _ in $(seq 1 60); do
if curl -s -o /dev/null "$BASE/orders/1"; then exit 0; fi
if ! kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then echo "application exited - see $LOG" >&2; exit 1; fi
sleep 0.5
done
echo "application did not start within 30s - see $LOG" >&2; exit 1
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
source "$(dirname "$0")/env.sh"
if [ -f "$PIDFILE" ]; then
PID="$(cat "$PIDFILE")"
kill "$PID" 2>/dev/null || true
for _ in $(seq 1 40); do kill -0 "$PID" 2>/dev/null || break; sleep 0.25; done
kill -9 "$PID" 2>/dev/null || true
rm -f "$PIDFILE"
fi