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,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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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/"
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user