Old-School Backend / 2026 ・ No.07

いにしえのバックエンド入門

第7回 例外と返事 ── 「システムエラーが発生しました」から卒業する

公開

端末の画面。品目が空で金額がマイナスの申請を Accept-Language: ja で送ると、HTTP/1.1 400、Content-Type: application/problem+json。本文は detail 直してから、もう一度送ってください、title 入力に誤りがあります、type https://expenses.example/problems/invalid-request、errors に item 空白は許可されていません、amount 0 より大きな値にしてください
例外の係を足したあとの 400。どの項目が、なぜだめかが、利用者の言葉で返ってきます(筆者がクラウド上の Linux で、2026年10月31日に撮影。ウインドウの上の帯は説明のために描き足したもの)

前回、サーバーは申請を断れるようになりました。ところが返事は「Bad Request」か「500」だけ。画面は利用者に何と言えばいいのか分かりません。
今回は、例外をどこで受け止め、どの状態コードで、何を書いて返すかを決めます。何も足さない Spring Boot、ProblemDetail(RFC 9457)、例外の係(@RestControllerAdvice)、握りつぶし、問い合わせ番号、そして返してはいけないものを実測します。

一

「システムエラーが発生しました」

新井エラーのときは「システムエラーが発生しました。管理者にお問い合わせください」でいいですよね。

古井その画面を見た利用者は、何を直せばいい?

新井……管理者に聞く?

古井そして管理者の私は、どのログを見ればいい? いにしえの現場では、その一文で何百通もの問い合わせが来た。

全15回の路線図(いまここ:第7回)

1 全体図 2 Java の準備 3 素の HTTP 4 素の JDBC 5 Spring Boot 6 入力チェック 7 例外と返事 8 DB アクセス 9 トランザクション 10 状態遷移 11 認証・認可 12 ファイル 13 ログと監視 14 テスト 15 動かす いまここ 基礎 素手で 受け口 データ 業務と守り 運用
  • 何も足さない ── 「ない受付番号」も「二度押し」も 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 Bad Request 400+項目ごとの理由 JSON が壊れている 400 Bad Request 400「申請の形が読めません」 ない受付番号 500 404「申請が見つかりません」 同じ申請を2回 500 409「同じ申請がすでに…」 DB が断る 500 500+問い合わせ番号 「ない」と「二度押し」が 500 だった。利用者のせいなのに、サーバーの故障に見える ProblemDetail を有効にするだけでは、400 の形がそろうだけ(実測) どの例外をどの状態コードにするかは、アプリの決まり。係に1か所で書く

400 には項目ごとの理由が付き、ない受付番号は 404、二度押しは 409(Conflict、いまの状態とぶつかる)になりました。DB が断ったものだけが 500 で、そこには問い合わせ番号が付いています。窓口のコードには try-catch が1つもありません。

ProblemDetail の項目と、読む人

ProblemDetail(RFC 9457)の項目と、読む人 type …/problems/duplicate プログラムが分岐に使う title 同じ申請がすでにあります 人が読む status 409 プログラムが分岐に使う detail 同じ日・品目・金額の申請は、1回だけ出せます 人が読む instance /api/v3/expenses プログラムが分岐に使う errors [{field, message}…] 画面が項目の横に出す errorId ERR-6ec9cea0 サポートがログを探す ここから下は、自分で足した項目(拡張) type は問題の種類の名前。文言が変わっても、type で分岐できる 画面は status と type を見て、出し方を選ぶ
五

理由を、利用者の言葉で

係を足した直後の 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 です。係は中身を返事に書かず、番号だけを返し、同じ番号でログに全部を書きます。

端末の画面。下で DB が断る申請を送ると、500 の本文に title 処理できませんでした、errorId ERR-a6a0bb49。上のアプリのログには ERROR ApiErrors 問い合わせ番号 ERR-a6a0bb49 と、DataIntegrityViolationException、SQL、Failing row contains の行
利用者に返したのは「処理できませんでした」と問い合わせ番号だけ(下)。同じ番号で、ログには SQL と失敗した行まで全部残っています(上)(筆者がクラウド上の Linux で、2026年10月31日に撮影。ウインドウの上の帯は説明のために描き足したもの)
# そのとき、アプリのログ(同じ問い合わせ番号で、中身が全部ある)
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行目にたどり着けます。逆に、中身を返事に書いてしまう設定もあります。

端末の画面。include-message と include-stacktrace を always にしたアプリに DB が断る申請を送ると、500 の JSON に trace としてスタックトレースが入り、SQL の insert into expense の列名、CHECK 制約の名前 amount_positive、失敗した行の中身、Spring と PostgreSQL ドライバーのクラス名と行番号が見える
やってはいけない設定。返事の本文に、表と列の名前、制約の名前、行の中身、使っている部品の名前と版の手がかりまで載りました(筆者がクラウド上の Linux で、2026年10月31日に撮影。ウインドウの上の帯は説明のために描き足したもの)
# 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か所で返事になる

倉庫番 Repository 担当者 Service 窓口 Controller 例外の係 ApiErrors 例外は上へ投げられ、1か所で返事になる DB の例外を Spring の例外に訳す 業務の例外を投げる (代理人が巻き戻す) try-catch を 書かない 種類ごとに 状態コードを選ぶ application/problem+json 状態コード+理由 ログ:中身を全部 スタックトレース、SQL、問い合わせ番号 途中では捕まえない。捕まえて何もしないと、失敗が成功に化ける(第6章) 担当者は「ない」「もうある」を業務の例外として投げるだけ。HTTP は知らない 係は例外の種類から状態コードと文言を決める。窓口ごとに書かなくてよい 利用者には直し方を、ログには調べる材料を。2つを問い合わせ番号で結ぶ
POINT

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.* に移りました。古い記事の設定名をそのまま書いても効かないことがあります。この実験でも、古い名前では何も変わりませんでした。

結 — 一枚まとめと、次回

  1. 何も足さないと、「ない」も「二度押し」も 500。利用者のせいの失敗が、故障に見える。
  2. ProblemDetail(RFC 9457)で形をそろえる。ただし、どの例外を何にするかはアプリが決める。
  3. 例外は担当者が投げ、@RestControllerAdvice の係が1か所で状態コードと文言にする。窓口に try-catch は要らない。
  4. 400 には項目ごとの理由を、利用者の言語で。
  5. 握りつぶさない。書けていないのに 201 を返す、うその成功がいちばん悪い。
  6. 500 は中身を見せず、問い合わせ番号でログと結ぶ。スタックトレースは返さない。

新井これで「システムエラーが発生しました」から卒業ですね。

古井卒業だ。次回は倉庫番に戻る。手で書いた SQL の JdbcClient と、JPA と、MyBatis。同じ申請一覧を3通りで書いて、何が起きるかを比べよう。

参考

記録は、筆者がクラウド上の 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

← 記事一覧へ