レッスン5: エラーハンドリングとリトライ戦略
学習目標:
- 一時的なエラーと永続的なエラーを見分ける
- リトライ戦略とバックオフアルゴリズムをマスターする
- 補償アクションとロールバック機構の設計を学ぶ
前提: レッスン4: ステート管理とコンテキストの受け渡し | 次: レッスン6 >>
ワークフローではエラーが常態
あなたのワークフローは10回完璧に動作した。11回目、ステップ8でAPIが503を返した。ワークフローがクラッシュする。
そこでtry-catchを追加し、エラーをキャッチしてログに記録し、続行させた。12回目の実行で、データベース接続がタイムアウトした。ワークフローは続行したが、書き込みは失敗しており、今やデータは不整合な状態にある。
エラーハンドリングは「try-catchを追加する」ほど単純ではない。
ワークフローにおけるエラーハンドリングは、3つの問いに答えなければならない:1
- このエラーは一時的なものか、永続的なものか? (ネットワークジッターか、権限不足か)
- リトライすべきか、スキップすべきか、中断すべきか? (リトライで直るか、リトライで悪化するか)
- 中断する場合、すでに完了したステップをどうクリーンアップするか? (データベースをロールバックするか、キャンセル通知を送るか)
失敗したステップがオプショナル(例えば通知の送信)なら、スキップして先に進む。重要でない失敗が全体の実行を止めないようにすることを、graceful degradation(段階的機能縮退)と呼ぶ。しかし失敗したステップがクリティカルな場合、スキップすると不整合な状態が残るため、中断すべきである。
答えがなければ、あなたのワークフローは脆弱すぎる(小さなエラー1つで停止する)か、危険すぎる(エラーを無視して実行を続け、不整合な状態を残す)かのどちらかになる。2
エラーの分類: 一時的 vs 永続的
**一時的なエラー(transient errors)**は一時的なもので、リトライで成功する可能性がある。3
よくある一時的エラー:
- ネットワークタイムアウト
- サービス一時的に利用不可(503 Service Unavailable)
- レート制限(429 Too Many Requests)
- データベース接続プール枯渇
- 一時的なロック競合
共通点: これらは通常、リソース競合、ネットワーク変動、または一時的な過負荷から生じており、少し待ってからリトライすると成功する傾向がある。
**永続的なエラー(permanent errors)**はリトライで成功しない。コードや設定の修正が必要。2
よくある永続的エラー:
- 権限不足(401 Unauthorized, 403 Forbidden)
- リソースが見つからない(404 Not Found)
- 不正な入力(400 Bad Request)
- ビジネスロジックエラー(残高不足、在庫ゼロ)
- コードのバグ(null pointer、ゼロ除算)
共通点: これらは設定ミス、コードバグ、またはビジネスルール違反から生じており、リトライしてもリソースを無駄にするだけである。
見分け方:
リトライ戦略
一時的なエラーに対しては、リトライが最初の手である。しかしリトライには見た目以上のものがある。3
戦略1: 固定遅延リトライ
問題点: エラーが過負荷なサービスから来ている場合、すべてのクライアントが一斉にリトライすると過負荷がさらに悪化する(thundering-herd効果)。
戦略2: 指数バックオフ
利点: 各リトライで間隔が倍になり、サービスを叩き続けるのではなく回復のための時間を与える。3
戦略3: 指数バックオフ + ジッター
利点: ジッターにより複数のクライアントが完全に同じタイミングでリトライすることを防ぎ、負荷を分散させる。3
これがプロダクションで推奨される戦略である。4
戦略4: 選択的リトライ
核心的アイデア: 一時的なエラーのみをリトライする。永続的なエラーは即座にthrowし、無意味なリトライでサイクルを浪費しない。5
サーキットブレーカーパターン
問題点: サービスが失敗し続けている場合(例えばクラッシュしたデータベース)、すべてのリクエストが3回リトライすると、無駄にリソースを消費してワークフロー全体を引きずり下ろす。そしてそのサービスが他のサービスの依存関係である場合、失敗はチェーン全体に伝播してカスケード障害になる。
サーキットブレーカー: エラー率がしきい値を超えると、失敗しているサービスの呼び出しを一時的に停止し、代わりに即座に失敗させることで、リソースの無駄を回避する。1
3つの状態
Closed: 正常に動作中。リクエストは通過し、ブレーカーはエラー率を追跡する。
Open: サービスは利用不可とみなされる。リクエストは呼び出しなしで即座に失敗する。
Half-open: タイムアウト後、いくつかの試験的なリクエストが通過する。成功すればブレーカーはclosedに戻る。そうでなければopenのままである。
実装
使うべき場面: 外部サービス、データベース、ファイルシステム、その他一括で失敗し得る依存関係への呼び出し。1
補償アクションとロールバック
問題点: ワークフローが3つの書き込みを行った(データベースへの書き込み、メール送信、キャッシュ更新)後、ステップ4が失敗した。最初の3つをどうアンドゥするか?2
パターン1: トランザクショナルな操作
適合する場面: すべての操作がトランザクションをサポートする同一データベース内にある。
限界: システムをまたげない(例えばデータベース + ファイルシステム + API呼び出し)。
パターン2: 補償アクション(Sagaパターン)
アイデア: 各操作に補償アクションを定義し、失敗時には補償を実行してすでに完了したステップをアンドゥする。4
要点:
- すべてのステップに
forward(アクション)とcompensate(アンドゥ)がある。
- 失敗時には、完了したステップの補償を逆順に実行する。
- 補償自体も失敗する可能性がある。ログに記録し、人間にフラグを立てる。4
パターン3: べき等性のある設計
べき等: N回実行しても1回実行したのと同じ効果になる。2
利点: ネットワークの不具合でステップが2回実行された場合(最初の試行はタイムアウトしたが実際には成功していた)、べき等性により重複した副作用が発生しないことを保証する。2
エラーハンドリングの3つの層
優れたワークフローは3つの層でエラーを処理する:
層1: 個別の操作
層2: ワークフローステップ
この層はすべての失敗をworkflowState.errorsに、ステップ名、エラーメッセージ、タイムスタンプとともに書き込む。これがエラーログであり、デバッグ時には記憶ではなくこの記録に頼る。
層3: ワークフロー全体
3層の保護: 操作層でリトライ、ステップ層で記録、ワークフロー層で回復と通知。
次: レッスン6: 実践での実世界ワークフロー — すべてをまとめて3つのプロダクショングレードなワークフローを構築する: コードリファクタリング、ドキュメント生成、テスト自動化