Old-School Backend / 2026 ・ No.07
いにしえのバックエンド入門
第7回 例外と返事 ── 「システムエラーが発生しました」から卒業する
公開
前回、サーバーは申請を断れるようになりました。ところが返事は「Bad Request」か「500」だけ。画面は利用者に何と言えばいいのか分かりません。
今回は、例外をどこで受け止め、どの状態コードで、何を書いて返すかを決めます。何も足さない Spring Boot、ProblemDetail(RFC 9457)、例外の係(@RestControllerAdvice)、握りつぶし、問い合わせ番号、そして返してはいけないものを実測します。
「システムエラーが発生しました」
新井エラーのときは「システムエラーが発生しました。管理者にお問い合わせください」でいいですよね。
古井その画面を見た利用者は、何を直せばいい?
新井……管理者に聞く?
古井そして管理者の私は、どのログを見ればいい? いにしえの現場では、その一文で何百通もの問い合わせが来た。
全15回の路線図(いまここ:第7回)
- 何も足さない ── 「ない受付番号」も「二度押し」も 500。利用者のせいなのに、故障に見える(実測)
- ProblemDetail ── 設定1つで形はそろうが、中身は「Invalid request content.」(実測)
- 例外の係 ── 400 に項目ごとの理由、404、409、500 に問い合わせ番号(実測)
- 握りつぶし ── 書けていないのに 201 と受付番号を返した(実測)
- 返してはいけないもの ── 設定1つで、SQL も行の中身も返事に載った(実測)
アプリは前回の Spring Boot 4.1.2 に、実験用の窓口(/api/v3)と、例外を握りつぶす担当者、例外の係(ApiErrors)を足しました。DB には、同じ申請者・日付・品目・金額の申請を1つに限る一意の索引を足してあります(二度押しの実験用)。
何も足さない返事
まず、何も足さない Spring Boot に、5種類の困った頼みごとを送りました。
# 何も足さない Spring Boot の返事
## 1 印に合わない(品目が空、金額がマイナス)
HTTP/1.1 400 Content-Type: application/json
{"timestamp":"2026-10-30T16:08:06.420Z","status":400,"error":"Bad Request","path":"/api/v3/expenses"}
## 2 JSON が壊れている
HTTP/1.1 400 Content-Type: application/json
{"timestamp":"2026-10-30T16:08:06.519Z","status":400,"error":"Bad Request","path":"/api/v3/expenses"}
## 3 ない受付番号
HTTP/1.1 500 Content-Type: application/json
{"timestamp":"2026-10-30T16:08:06.609Z","status":500,"error":"Internal Server Error","path":"/api/v3/expenses/EXP-2026-9999"}
## 4 同じ申請を2回(二度押し)
HTTP/1.1 201 Content-Type: application/json
{"id":"EXP-2026-2894"}
HTTP/1.1 500 Content-Type: application/json
{"timestamp":"2026-10-30T16:08:06.763Z","status":500,"error":"Internal Server Error","path":"/api/v3/expenses"}
## 5 DB が断る(@Valid のない窓口から、マイナスの金額)
HTTP/1.1 500 Content-Type: application/json
{"timestamp":"2026-10-30T16:08:06.824Z","status":500,"error":"Internal Server Error","path":"/api/novalid/expenses"}
印に合わない申請と壊れた JSON は 400。ここまではよいのですが、どちらも「Bad Request」としか書いてありません。そして「ない受付番号」と「同じ申請を2回」は 500 でした。担当者が投げた「ない」という例外も、DB の一意の索引が断った二度押しも、だれも受け止めなかったので「サーバーの故障」として返ったのです。利用者のせいの失敗が、サーバーの故障に見える。監視の仕組みは 500 を数えて警報を鳴らすので、運用の人も夜中に起こされます(第13回)。
ProblemDetail を有効にする
HTTP の API が失敗を返すときの形として、ProblemDetail(RFC 9457、以前は RFC 7807)という決まりがあります。Spring Boot では設定1つで有効になります。
# spring.mvc.problemdetails.enabled=true で起動すると
## 1 印に合わない(品目が空、金額がマイナス)
HTTP/1.1 400 Content-Type: application/problem+json
{"detail":"Invalid request content.","instance":"/api/v3/expenses","status":400,"title":"Bad Request"}
## 2 JSON が壊れている
HTTP/1.1 400 Content-Type: application/problem+json
{"detail":"Failed to read request","instance":"/api/v3/expenses","status":400,"title":"Bad Request"}
## 3 ない受付番号
HTTP/1.1 500 Content-Type: application/json
{"timestamp":"2026-10-30T16:08:13.943Z","status":500,"error":"Internal Server Error","path":"/api/v3/expenses/EXP-2026-9999"}
## 4 同じ申請を2回(二度押し)
HTTP/1.1 201 Content-Type: application/json
{"id":"EXP-2026-2897"}
HTTP/1.1 500 Content-Type: application/json
{"timestamp":"2026-10-30T16:08:14.102Z","status":500,"error":"Internal Server Error","path":"/api/v3/expenses"}
## 5 DB が断る(@Valid のない窓口から、マイナスの金額)
HTTP/1.1 500 Content-Type: application/json
{"timestamp":"2026-10-30T16:08:14.161Z","status":500,"error":"Internal Server Error","path":"/api/novalid/expenses"}
400 は application/problem+json になり、title・detail・instance がそろいました。ただし中身は「Invalid request content.」で、どの項目が悪いかは分かりません。そして 404 になるべきもの、409 になるべきものは、あいかわらず 500 です。形をそろえる仕組みはあっても、どの例外を何にするかはアプリが決めるしかありません。
例外の係を1つ置く
担当者は、申請が「ない」ことを業務の例外として投げるだけにします。HTTP のことは知りません。
@Transactional(readOnly = true)
public Expense find(String id) {
return repo.findById(id).orElseThrow(() -> new ExpenseNotFoundException(id)); // ない → 業務の例外
}
そして、アプリ全体で1つ、例外を受け止めて返事を作る係を置きます。@RestControllerAdvice を付けたクラスの @ExceptionHandler が、例外の種類ごとに状態コードと文言を決めます。
@RestControllerAdvice
@ConditionalOnProperty("demo.advice")
public class ApiErrors {
private static final Logger log = LoggerFactory.getLogger(ApiErrors.class);
@ExceptionHandler(MethodArgumentNotValidException.class) // 印に合わない → 400、項目ごとの理由
ProblemDetail invalid(MethodArgumentNotValidException e) {
ProblemDetail p = problem(HttpStatus.BAD_REQUEST, "invalid-request", "入力に誤りがあります", "直してから、もう一度送ってください");
List<Map<String, String>> errors = e.getBindingResult().getFieldErrors().stream()
.map(f -> Map.of("field", f.getField(), "message", String.valueOf(f.getDefaultMessage()))).toList();
p.setProperty("errors", errors);
return p;
}
@ExceptionHandler(HttpMessageNotReadableException.class) // JSON として読めない → 400
ProblemDetail unreadable(HttpMessageNotReadableException e) {
return problem(HttpStatus.BAD_REQUEST, "unreadable", "申請の形が読めません", "JSON の書き方か、項目の型が違います");
}
@ExceptionHandler(ExpenseNotFoundException.class) // ない → 404
ProblemDetail notFound(ExpenseNotFoundException e) {
return problem(HttpStatus.NOT_FOUND, "expense-not-found", "申請が見つかりません", "受付番号 " + e.id() + " の申請はありません");
}
@ExceptionHandler(DuplicateKeyException.class) // 同じ申請がもうある → 409
ProblemDetail duplicate(DuplicateKeyException e) {
return problem(HttpStatus.CONFLICT, "duplicate", "同じ申請がすでにあります", "同じ日・品目・金額の申請は、1回だけ出せます");
}
@ExceptionHandler(Exception.class) // それ以外 → 500。中身は見せず、番号で結ぶ
ProblemDetail unexpected(Exception e) {
String no = "ERR-" + HexFormat.of().toHexDigits(ThreadLocalRandom.current().nextInt());
log.error("問い合わせ番号 {}", no, e);
ProblemDetail p = problem(HttpStatus.INTERNAL_SERVER_ERROR, "unexpected", "処理できませんでした", "時間をおいてもう一度お試しください。続くときは問い合わせ番号をお知らせください");
p.setProperty("errorId", no);
return p;
}
# 例外を受け止める係(ApiErrors)を足すと
## 1 印に合わない(品目が空、金額がマイナス)
HTTP/1.1 400 Content-Type: application/problem+json
{"detail":"直してから、もう一度送ってください","instance":"/api/v3/expenses","status":400,"title":"入力に誤りがあります","type":"https://expenses.example/problems/invalid-request","errors":[{"field":"item","message":"must not be blank"},{"field":"amount","message":"must be greater than 0"}]}
## 2 JSON が壊れている
HTTP/1.1 400 Content-Type: application/problem+json
{"detail":"JSON の書き方か、項目の型が違います","instance":"/api/v3/expenses","status":400,"title":"申請の形が読めません","type":"https://expenses.example/problems/unreadable"}
## 3 ない受付番号
HTTP/1.1 404 Content-Type: application/problem+json
{"detail":"受付番号 EXP-2026-9999 の申請はありません","instance":"/api/v3/expenses/EXP-2026-9999","status":404,"title":"申請が見つかりません","type":"https://expenses.example/problems/expense-not-found"}
## 4 同じ申請を2回(二度押し)
HTTP/1.1 201 Content-Type: application/json
{"id":"EXP-2026-2900"}
HTTP/1.1 409 Content-Type: application/problem+json
{"detail":"同じ日・品目・金額の申請は、1回だけ出せます","instance":"/api/v3/expenses","status":409,"title":"同じ申請がすでにあります","type":"https://expenses.example/problems/duplicate"}
## 5 DB が断る(@Valid のない窓口から、マイナスの金額)
HTTP/1.1 500 Content-Type: application/problem+json
{"detail":"時間をおいてもう一度お試しください。続くときは問い合わせ番号をお知らせください","instance":"/api/novalid/expenses","status":500,"title":"処理できませんでした","type":"https://expenses.example/problems/unexpected","errorId":"ERR-6ec9cea0"}
何も足さない Spring Boot と、係を足したあと(実測)
400 には項目ごとの理由が付き、ない受付番号は 404、二度押しは 409(Conflict、いまの状態とぶつかる)になりました。DB が断ったものだけが 500 で、そこには問い合わせ番号が付いています。窓口のコードには try-catch が1つもありません。
ProblemDetail の項目と、読む人
理由を、利用者の言葉で
係を足した直後の 400 の理由は「must not be blank」と英語でした。同じ申請を、言語の希望(Accept-Language)を変えて送りました。
# 同じ申請を、Accept-Language を変えて
## 1 印に合わない(Accept-Language: ja)
HTTP/1.1 400 Content-Type: application/problem+json
{"detail":"直してから、もう一度送ってください","instance":"/api/v3/expenses","status":400,"title":"入力に誤りがあります","type":"https://expenses.example/problems/invalid-request","errors":[{"field":"item","message":"空白は許可されていません"},{"field":"amount","message":"0 より大きな値にしてください"}]}
## 1 印に合わない(Accept-Language: en)
HTTP/1.1 400 Content-Type: application/problem+json
{"detail":"直してから、もう一度送ってください","instance":"/api/v3/expenses","status":400,"title":"入力に誤りがあります","type":"https://expenses.example/problems/invalid-request","errors":[{"field":"amount","message":"must be greater than 0"},{"field":"item","message":"must not be blank"}]}
Accept-Language: ja なら「空白は許可されていません」。Spring は頼みごとの言語に合わせて、印の文言を選びます。ブラウザーは利用者の言語を自動で送るので、画面からの申請は日本語の理由になります。ただし「空白は許可されていません」は少し硬いので、本番では印に自分の文言(message = "品目を入れてください")を書くか、文言の一覧(ValidationMessages.properties)を用意します。
握りつぶさない
係を置く前の、いにしえのコードによくある書き方です。例外を捕まえて、印刷だけして、先へ進みます。
# いにしえの担当者(SwallowService の要点)
public String submit(ExpenseRequest req) {
String id = "EXP-2026-%04d".formatted(repo.nextNumber());
try {
repo.insert(id, "新井", req);
} catch (Exception e) {
e.printStackTrace(); // 画面には何も伝わらない
}
return id; // 書けていなくても、受付番号を返す
}
## 6 握りつぶす担当者に、マイナスの金額
HTTP/1.1 201 Content-Type: application/json
{"id":"EXP-2026-2903"}
# その受付番号を見に行く
HTTP/1.1 404 Content-Type: application/problem+json
{"detail":"受付番号 EXP-2026-2903 の申請はありません","instance":"/api/v3/expenses/EXP-2026-2903","status":404,"title":"申請が見つかりません","type":"https://expenses.example/problems/expense-not-found"}
# アプリのログ(標準エラーに出たスタックトレースの一部)
at java.base/java.lang.Thread.run(Thread.java:1474)
Caused by: org.postgresql.util.PSQLException: ERROR: new row for relation "expense" violates check constraint "amount_positive"
Detail: Failing row contains (EXP-2026-2903, 2026-10-28, 交通費, -1280.00, submitted, 2026-10-31 01:08:22.039223+09, 新井).
at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2993)
マイナスの金額は DB に断られたのに、画面には 201 と受付番号が返りました。利用者は「出せた」と思って帰り、月末に経理から「そんな申請はない」と言われます。ログには例外が出ていますが、どの頼みごとの失敗かを結ぶものは何もありません。捕まえて何もしないくらいなら、捕まえない。例外は係まで上げれば、トランザクションも巻き戻り(第5回)、返事も正しくなります。
新井受付番号まで出たら、だれだって信じますよね……。
古井エラーより悪いのは、うその成功だ。エラーは利用者が気づくが、うその成功は1か月後に経理が気づく。
500 は、問い合わせ番号で結ぶ
どの例外にも当たらなかったもの(DB が断った、相手のシステムが落ちた、プログラムの誤り)は、本当にサーバー側の問題なので 500 です。係は中身を返事に書かず、番号だけを返し、同じ番号でログに全部を書きます。
# そのとき、アプリのログ(同じ問い合わせ番号で、中身が全部ある) 01:08:21.825 http-nio-8080-exec-7 ERROR ApiErrors 問い合わせ番号 ERR-6ec9cea0 org.springframework.dao.DataIntegrityViolationException: PreparedStatementCallback; SQL [insert into expense (id, applicant, applied_on, item, amount, status) values (?, ?, ?, ?, ?, 'submitted')]; ERR Detail: Failing row contains (EXP-2026-2902, 2026-10-28, 交通費, -1280.00, submitted, 2026-10-31 01:08:21.818124+09, 新井). at org.springframework.jdbc.support.SQLStateSQLExceptionTranslator.doTranslate(SQLStateSQLExceptionTranslator.java:140)
利用者が「ERR-6ec9cea0 と出ました」と知らせてくれれば、ログの1行目にたどり着けます。逆に、中身を返事に書いてしまう設定もあります。
# spring.web.error.include-message=always と include-stacktrace=always で起動すると
## 5 DB が断る(include-message=always、include-stacktrace=always)
HTTP/1.1 500 Content-Type: application/json
{"timestamp":"2026-10-30T16:08:28.782Z","status":500,"error":"Internal Server Error","trace":"org.springframework.dao.DataIntegrityViolationException: PreparedStatementCallback; SQL [insert into expense (id, applicant, applied_on, item, amount, status) v…(91 行、8842 文字)","message":"PreparedStatementCallback; SQL [insert into expense (id, applicant, applied_on, item, amount, status) values (?, ?, ?, ?, ?, 'submitted')]; ERROR: new row for relation \"expense\" violates check constraint \"amount_positive\"\n Detail: Failing row contains (EXP-2026-2904, 2026-10-28, 交通費, -1280.00, submitted, 2026-10-31 01:08:28.733548+09, 新井).","path":"/api/novalid/expenses"}
返事に 91 行、8,842 文字のスタックトレースと、SQL、表と列の名前、制約の名前、失敗した行の中身が載りました。使っている部品の名前や行番号は、攻撃する側にとって「どの弱点を試すか」の手がかりになります(第11回)。include-stacktrace と include-message は、開発中の自分の PC 以外では使いません。
状態コードの選び方
| 状態コード | 意味 | 経費精算での例 | 直すのは |
|---|---|---|---|
| 400 | 頼みごとの形がおかしい | 品目が空、JSON が壊れている | 送った側(直せば通る) |
| 401 / 403 | だれか分からない/権限がない | ログインしていない、他人の申請(第11回) | 送った側 |
| 404 | ない | ない受付番号 | 送った側 |
| 409 | いまの状態とぶつかる | 二度押し、承認済みの申請を直す(第10回) | 送った側(状態を見直す) |
| 500 | サーバー側の問題 | DB が断った、プログラムの誤り | サーバー側(問い合わせ番号) |
| 503 | いまは受けられない | 止めて入れ替え中、混みすぎ(第15回) | 待てば直る |
例外は上へ投げられ、1か所で返事になる
4 で始まる番号は「送った側が直せる」、5 で始まる番号は「サーバー側の問題」。この区別を守ると、画面は直し方を出すか謝るかを選べ、監視は本当の故障だけを数えられます。迷ったら「利用者が何かを変えれば通るか」を問います。
画面はどう受け取るか
フロントエンド編の第9回で作った「項目の横に赤い文字」は、サーバーからの理由でも出せます。たとえば、こう受け取ります。
const res = await fetch("/api/v3/expenses", {
method: "POST",
headers: { "Content-Type": "application/json" }, // Accept-Language はブラウザーが付ける
body: JSON.stringify(form),
});
if (res.ok) { /* 受付番号を出す */ }
else if (res.headers.get("Content-Type")?.startsWith("application/problem+json")) {
const p = await res.json();
(p.errors ?? []).forEach(e => showFieldError(e.field, e.message)); // 項目の横に
if (p.status === 409) showMessage(p.title); // 二度押し
if (p.errorId) showMessage(`${p.title}(問い合わせ番号 ${p.errorId})`);
}
画面は status と type で出し方を選び、title と detail をそのまま見せられます。二度押しそのものは、画面側でもボタンを押せなくする(フロントエンド編)。サーバーの 409 は、それをすり抜けたときの最後の歯止めです。
自分の PC で確かめる
| 試したいこと | 書き方 |
|---|---|
| ProblemDetail の形にする | spring.mvc.problemdetails.enabled=true |
| 例外の係を置く | @RestControllerAdvice + @ExceptionHandler |
| 項目を足す | problem.setProperty("errors", …) |
| 言語を変えて確かめる | curl -H 'Accept-Language: ja' … |
| 返事の形と状態コードを見る | curl -si … | head |
| 使ってはいけない(本番) | spring.web.error.include-stacktrace=always |
Spring Boot 4 では、返事にスタックトレースなどを含める設定の名前が server.error.* から spring.web.error.* に移りました。古い記事の設定名をそのまま書いても効かないことがあります。この実験でも、古い名前では何も変わりませんでした。
結 — 一枚まとめと、次回
- 何も足さないと、「ない」も「二度押し」も 500。利用者のせいの失敗が、故障に見える。
- ProblemDetail(RFC 9457)で形をそろえる。ただし、どの例外を何にするかはアプリが決める。
- 例外は担当者が投げ、@RestControllerAdvice の係が1か所で状態コードと文言にする。窓口に try-catch は要らない。
- 400 には項目ごとの理由を、利用者の言語で。
- 握りつぶさない。書けていないのに 201 を返す、うその成功がいちばん悪い。
- 500 は中身を見せず、問い合わせ番号でログと結ぶ。スタックトレースは返さない。
新井これで「システムエラーが発生しました」から卒業ですね。
古井卒業だ。次回は倉庫番に戻る。手で書いた SQL の JdbcClient と、JPA と、MyBatis。同じ申請一覧を3通りで書いて、何が起きるかを比べよう。
参考
- RFC 9457 — Problem Details for HTTP APIs
- RFC 9110 — HTTP Semantics(Status Codes)
- Spring Framework — Error Responses(ProblemDetail)
- Spring Framework — Controller Advice
- Spring Boot — Servlet Web Applications(Error Handling)
- OWASP — Error Handling Cheat Sheet
記録は、筆者がクラウド上の Linux(Ubuntu 24.04、カーネル 6.18)で、Spring Boot 4.1.2(Spring Framework 7、Tomcat 11、Hibernate Validator 9)、OpenJDK 25.0.4、PostgreSQL 16.15 を使って 2026年10月31日に取ったものです。PC・申請サーバー・DB サーバーは、1台の中に作った別々のネットワーク空間(network namespace)です。問い合わせ番号は、実行するたびに変わります。画面のコードは説明のための例です。表の数字や出力はすべて、そのときのコマンドの出力から読んだ実際の値です。登場人物は架空の人物です。
いにしえのバックエンド入門 第7回 — 例外と返事 / 2026.10