エラーハンドリングとリトライ設計

エラーハンドリングとリトライ設計とは

外部のAPIやデータベースを呼ぶコードはいつか必ず失敗します。エラーハンドリングとは、その失敗を握りつぶさずに扱い、安全に再試行するための設計です。

初学者が最初に詰まるのが「TCPが再送してくれるのに、なぜアプリでもリトライが要るのか」です。[[TCPの信頼性制御]] が保証するのはバイト列が相手のTCPスタックまで届くことであって、サーバーが処理を完了したことではありません。

TCPが保証する : バイト列が相手のTCPスタックに届く
保証しない    : アプリが受け取ったか / DBに書けたか / 応答が返るか

届いた直後にサーバーが落ちれば処理は消えます。接続が途中で切れれば、処理済みかどうかは呼んだ側から判別できません。だからアプリ層でも失敗の扱いを自分で設計します。

再試行してよいエラー・してはいけないエラー

すべての失敗を再試行するのは間違いです。エラーはまず3種類に仕分けます。

種類リトライ
一時的な失敗接続拒否・503・[[レートリミット]] の429・DBのデッドロックする
恒久的な失敗400 Bad Request・401 未認証・404・入力値の検証エラーしない
結果が不明タイムアウト・接続の途中切断冪等ならする

基準は「もう一度同じことをすれば成功しうるか」です。中身が誤っている400を100回送っても100回失敗するだけです(コードの意味は [[HTTPステータスコード]] を参照)。

最も注意が要るのが結果が不明なケースです。応答が返らなかっただけで、サーバー側では処理が終わっているかもしれません。安全に再送できるかはその操作が [[冪等性]] を持つかで決まります。冪等でない操作(「残高から1000円引く」など)を結果不明のままリトライすると二重決済が起きます。リトライ設計は冪等性の確保とセットです。

タイムアウトを必ず設定する

タイムアウトの無い呼び出しは、相手が黙り込むと永遠に待ち続けます。待つ間スレッドや [[コネクションプーリング]] の接続は掴まれたままで、呼び出し先が遅くなっただけで呼ぶ側のリソースが枯渇します。接続タイムアウト(つながるまで)と読み取りタイムアウト(応答が返るまで)を分けて設定します。

値は勘ではなく実測で決めます。相手の応答時間の分布(99パーセンタイルなど)を測り、その少し上に置く。短すぎれば成功するはずの処理を殺し、長すぎれば詰まりを検知できません。

もう一つがタイムアウトの予算です。呼び出し元へ3秒以内に応答すると約束しているのに、内部で10秒待つ設定は矛盾しています。階層をまたいで内側ほど短くなるよう配分します。

指数バックオフとジッター

再試行をすぐに・等間隔で繰り返すのは最悪の手です。弱ったサーバーに追い打ちをかけ、復旧を遅らせます。定石は待ち時間を回ごとに倍へ延ばす指数バックオフです。

さらにジッター(ゆらぎ)を加えます。多数のクライアントが同時に失敗すると、バックオフだけでは全員が同じ時刻に再送し、復旧の瞬間にまた波が来ます(サンダリングハード問題、thundering herd)。乱数でばらせば波を崩せます。

const MAX_ATTEMPTS = 3; // 回数と合計時間の両方で上限を切る
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
  try {
    return await callApi();
  } catch (err) {
    if (!isRetryable(err) || attempt === MAX_ATTEMPTS - 1) throw err;
    const base = 200 * 2 ** attempt; // 200ms → 400ms と回ごとに倍へ
    await sleep(base * (0.5 + Math.random())); // ジッターでばらす
  }
}

「3回まで、かつ合計5秒まで」と決めておけば、上位のタイムアウト予算をはみ出しません。相手が Retry-After を返すなら、自分の計算よりそちらを優先します。

サーキットブレーカー

リトライは相手がすぐ回復する前提の戦術です。数分ダウンしている相手に全クライアントがリトライを続けると、復旧しかけたサーバーが再び潰れ、呼ぶ側も待ちを抱え込んで障害が呼び出し元へ伝播します。これがカスケード障害です。

対策がサーキットブレーカー(電気のブレーカーが由来)です。失敗率を監視し、閾値を超えたら呼ぶこと自体をやめて即座に失敗を返します

状態振る舞い
Closed(閉)通常どおり呼び出し、失敗率を数える
Open(開)呼び出さず即エラー。相手を休ませ、自分の待ちも減らす
Half-Open一定時間後に少数だけ試し、成功すればClosedへ戻す

サービスが網の目に依存し合う [[マイクロサービス]] で特に重要で、[[高可用性設計]] の「障害を局所に閉じ込める」考え方をアプリ層で実現する道具です。

失敗の記録と外への見せ方

  • 例外を握りつぶさないcatch して何もしないコードは、障害を「静かに壊れる」ものに変えます。再試行しないと判断したなら理由をログに残して上位へ投げ直します
  • 500を返す前に文脈を残す — 「Error occurred」だけでは調査できません。どのIDを・どの外部呼び出しで・何回目の試行で失敗したかを [[ログ設計]] の構造化ログに記録します
  • スタックトレースを外部に出さない — 例外の詳細をレスポンスに載せると、内部のファイルパスやSQLの断片が攻撃者に渡ります。外向きには短いメッセージと追跡用のリクエストIDだけを返します

初学者向けポイント

  • リトライを足す前に「この処理は2回動いても安全か」を問う。ノーなら先に [[冪等性]] を確保する
  • リトライは呼び出しの総量を増やす行為でもある。3回再試行する処理を3階層重ねると最悪27回呼ばれる。再試行は階層のどこか1箇所へ寄せるのが原則
  • 「たまに落ちるからリトライで隠す」は危険。回復した失敗もWARNで記録し、[[監視とアラート]] で傾向を見る
  • タイムアウトとリトライは失敗させないと動作確認できないコード。相手を [[テストダブル(モック・スタブ)]] に差し替え、意図的に失敗させて試します

関連技術とのつながり

  • [[冪等性]] — 安全に再試行できるかを決める前提条件
  • [[TCPの信頼性制御]] — 再送は下位層も行うが、保証は到達までで処理完了ではない
  • [[レートリミット]] — 429と Retry-After はバックオフ設計の代表的な場面
  • [[ログ設計]] — 失敗の文脈をどう残すか。握りつぶさない実装の受け皿
  • [[マイクロサービス]] — 多段呼び出しほどタイムアウト予算とサーキットブレーカーが効く
Q: TCPが再送してくれるのに、アプリ側でもリトライが必要な理由はどれ?
- [x] TCPが保証するのはバイト列の到達までで、サーバーが処理を完了したことは保証しないから
- [ ] TCPの再送は暗号化された通信では動作しないから
- [ ] TCPの再送回数が仕様上1回に固定されているから
解説: 届いた直後にサーバーが落ちれば処理は消えます。到達の保証と処理完了の保証は別物です。

Q: 本文の分類で「リトライしてはいけない」エラーはどれ?
- [ ] 503 Service Unavailable
- [x] 400 Bad Request
- [ ] 429 Too Many Requests
解説: 400はリクエストの中身自体が誤っているため、同じ内容を送り直しても必ず失敗します。503や429は一時的な失敗で再試行の対象です。

Q: サーキットブレーカーがOpen(開)の状態で行うことはどれ?
- [ ] 待ち時間を倍にしながら再試行を続ける
- [x] 呼び出しをやめて即座に失敗を返し、相手を休ませる
- [ ] 失敗したリクエストをキューに保存して順番に再送する
解説: Openでは呼び出し自体を止めて即エラーを返します。弱った相手への追い打ちを避け、自分側の待ちも減らしてカスケード障害を防ぎます。