仕組みから学ぶ Web
応用読了目安 13#エラー設計#HTTPステータス#リトライ#API

エラー設計 — 呼び出し側が正しく振る舞える情報を返す

エラーは失敗の通知ではなく、次に何をすべきかの指示。リトライしてよいか、直せば通るかを伝える。

この記事の進み方

What — ステータスコードが伝えていること

HTTP ステータスコードの百の位は、誰が何をすべきかを表しています。

Figureステータスコードの分類
2xx 成功正常に処理された200 / 201 / 204
3xx リダイレクト別の場所を見てほしい301 / 304
4xx クライアントエラーリクエストに問題がある。直さないと通らない400 / 401 / 403 / 404 / 409 / 422 / 429
5xx サーバーエラーサーバー側の問題。リクエストは正しいかもしれない500 / 502 / 503 / 504
1/4
2xx 成功201 は作成、204 は成功したが返す本体がない。DELETE の成功は 204 が自然。

4xx は「あなたが直せば通る」、5xx は「こちらの問題なので、待てば通るかもしれない」。この区別がリトライの可否を決める。

よく使うコードの使い分け

400 Bad Request
リクエストの形式が不正。JSON がパースできない、必須項目がない。
401 Unauthorized
認証されていない。ログインすれば通る可能性がある。名前に反して「認証」の話。
403 Forbidden
認証済みだが権限がない。ログインし直しても通らない。
404 Not Found
リソースが存在しない。存在を隠したい場合、403 の代わりに使うこともある。
409 Conflict
現在の状態と矛盾する。すでにキャンセル済みの注文をキャンセルしようとした、など。
422 Unprocessable Entity
形式は正しいが内容が業務ルールに反する。メールアドレスの形式は正しいが既に使われている、など。
429 Too Many Requests
レート制限。4xx だが、待てば通る。Retry-After ヘッダで待ち時間を伝える。
503 Service Unavailable
一時的に利用できない。メンテナンス中や過負荷。Retry-After を付けられる。

エラーレスポンスの本体に何を入れるか

Compare呼び出し側が使える情報とは

何が悪いか分からず、次に何をすべきかも分からない。

HTTP 200 OK
{
"success": false,
"message": "エラーが発生しました"
}

クライアントにできること:
- メッセージをそのまま画面に出す
- 文字列で分岐する(サーバーの文言変更で壊れる)
- リトライすべきか判断できない
機械可読性
なし
リトライ判定
不可能
監視での検知
できない(全部 200)
どこを直すか
分からない
  • 文言で分岐すると、多言語化やメッセージ改善のたびにクライアントが壊れる。
  • ステータスが 200 だと、ロードバランサやプロキシの統計にもエラーとして現れない。

人間向けのメッセージだけでは、クライアントは分岐できない。機械が判定できるコードと、どのフィールドの問題かが必要。

確認 — ここまで読めたか

失敗も HTTP 200 で返し、ボディの success: false で表す方式。何が困りますか?

まず選ぶ(解答例は a〜d の記号で説明します)

Why — エラーは「次に何をすべきか」の指示

呼び出し側がエラーを受け取ったとき、判断すべきことは3つです。

  1. リトライしてよいか — 一時的な問題か、直さないと通らないか
  2. 何を直せばよいか — どのフィールドの、どういう問題か
  3. 利用者に何を伝えるか — そのまま表示してよい文言か

この3つに答えられないエラーレスポンスは、呼び出し側に「とりあえず何か失敗した」としか伝えていません

なぜ 200 で返してはいけないのか

「HTTP は転送の成功、業務エラーはボディで」という考え方には一理あります。しかし実務では次のコストが発生します。

  • 監視が機能しない。 エラー率、SLO、アラートがすべて HTTP ステータスを基準にしている
  • ロードバランサやプロキシが異常を検知できない。 5xx が続けばインスタンスを切り離す、といった仕組みが働かない
  • クライアントのライブラリが素直に使えない。 多くの HTTP クライアントは、ステータスで例外を投げるか否かを決める
  • CDN やキャッシュが誤ってキャッシュする。 200 はキャッシュ可能と解釈されうる

HTTP のエコシステム全体がステータスコードを前提に動いているので、そこから外れると多くの仕組みが無効になります。

内部情報を漏らさない

エラーメッセージは攻撃の手がかりになります。

// 危険
{ "message": "SQLSTATE[42S02]: Base table 'prod_db.users' doesn't exist" }
{ "message": "ユーザー ID 12345 は存在しますが、パスワードが違います" }

// 安全
{ "code": "INTERNAL_ERROR", "message": "処理に失敗しました", "traceId": "01H8X..." }
{ "code": "INVALID_CREDENTIALS", "message": "メールアドレスまたはパスワードが正しくありません" }
  • スタックトレースや SQL を返さない。 テーブル構造やフレームワークのバージョンが漏れる
  • 認証では「どちらが間違っているか」を言わない。 メールアドレスの存在確認に使われる
  • 代わりに traceId を返す。 詳細はサーバーのログにあり、問い合わせ時に突き合わせられる

リトライの作法

5xx を受け取ったクライアントは、リトライしてよい。ただしやり方が重要です。

// 悪い:全員が同じタイミングで殺到する
for (let i = 0; i < 3; i++) {
  try { return await call(); } catch { await sleep(1000); }
}

// 良い:指数バックオフ + ジッター
for (let i = 0; i < 3; i++) {
  try { return await call(); }
  catch (e) {
    if (!isRetryable(e)) throw e;          // 4xx はリトライしない
    const base = 2 ** i * 200;             // 200, 400, 800ms
    await sleep(base + Math.random() * base);  // ジッターで分散
  }
}

ジッター(ランダムなずれ)が重要です。 固定間隔だと、障害から復旧した瞬間に全クライアントが同時にリトライし、再び落ちます(サンダリングハード)。

確認 — ここまで読めたか

エラー応答に含めるべき、呼び出し側がいちばん必要とする情報はどれですか?

まず選ぶ(解答例は a〜d の記号で説明します)

演習 — まず自分で判断する

解説を読む前に、まず自分で判断してみてください。ここで一度詰まっておくと、 次の節の判断軸が「なるほど」ではなく「そう来たか」に変わります。

演習 — 設計判断を問う

この5つの失敗に、それぞれどう応答しますか?

与えられた条件
  • オンライン英会話の予約 API: POST /reservations
  • ① リクエストの JSON が壊れていてパースできない
  • ② 認証トークンの有効期限が切れている
  • ③ 予約しようとした枠が、直前に他の人に取られていた
  • ④ 保有チケットが足りない(予約には2枚必要だが1枚しかない)
  • ⑤ 予約 DB への接続がタイムアウトした
  • クライアントは Web とモバイルアプリの2種類。モバイルは自動リトライを実装している
この軸で考える
  • · そのリクエストを再送したら成功する可能性があるか
  • · 利用者が何かすれば通るのか、待てば通るのか、どちらでもないのか
  • · モバイルの自動リトライが、どのケースで動くべきか
まず選ぶ(解答例は a〜d の記号で説明します)

演習 — 説明できるか

「エラーは全部 200 とメッセージで返す方針にしたい」という提案に、何が起きるかを説明してください

与えられた条件
  • 相手はクライアント側の分岐を減らしたい
  • 監視とリトライの仕組みが既にある
  • 代案まで示したい

読み終わりましたか?

読了にすると、これを前提とする記事がロードマップで開放されます。