支払いAPI呼び出しが午前2時に失敗しました。ネットワークの一時的な問題だったのか、レート制限か、あるいはサーバーが停止していたのでしょうか?その答えによって、リトライがトランザクションを救うのか、それとも顧客に二重請求してしまうのかが決まります。 リトライは分散システムにおける最も一般的なレジリエンスパターンであり、同時に最も失敗しやすいものです。HTTP呼び出しをループで囲むのは防御的なプログラミングのように感じられます。しかし、間違って行うと、30秒間の障害が30分間に拡大してしまいます。なぜなら、何千ものクライアントが、すでに苦境にあるサーバーに一斉に攻撃を仕掛けるからです。正しく行えば、リトライは一時的な障害を非常にうまく吸収し、ユーザーはそれに気づくことすらありません。 このガイドでは、本番システムが依存するリトライロジックについて説明します。具体的には、どのステータスコードをリトライすべきか、フルジッター付き指数バックオフの計算式、Retry-Afterヘッダー、冪等性キー、リトライバジェット、そしてサーキットブレーカーです。また、Apidogのモックサーバーを使って429や503をシミュレートし、クライアントが正しく動作することを証明する方法も紹介します。なぜなら、障害を起こしているサーバーに対して一度もテストされていないリトライパターンは、設計ではなく推測に過ぎないからです。フィンテックAPIのリトライロジックを構築するチームは、このことを高い代償を払って学びます。あなたはそうする必要はありません。
安易なリトライが障害を悪化させる理由
1秒間に1,000リクエストを処理するサービスを想像してみてください。それが5秒間一時的に停止します。すべてのクライアントがすぐに、それぞれ3回リトライします。1,000rpsだった需要が、すでに膝をついているサーバーに4,000rpsで殺到します。サーバーは完全にダウンしてしまいます。そして今、すべてのクライアントが再びリトライします。 このフィードバックループには名前があります。「リトライストーム」です。サーバーが復旧したときに同期して殺到する現象は「雷鳴の群れ(thundering herd)」と呼ばれます。GoogleのSREブックでは、「カスケード障害への対処」の章でこのパターンを指摘しています。バックオフなしのリトライは、システムが最も余裕がないときに負荷を正確に増幅させ、元の障害が修正された後もサービスを長時間停止させ続ける可能性があります。 ほとんどのリトライストームは2つの設計上の欠陥によって引き起こされます。 * 再試行間の遅延がない。即時リトライは、最悪のタイミングで負荷を増大させます。 * 固定遅延。すべてのクライアントが正確に1秒間待機すると、すべてが同期して戻ってきます。サーバーは滑らかな増加ではなく、同期したトラフィックの波を受け取ることになります。 解決策は「決してリトライしない」ではありません。解決策は、選択的に、ランダムな遅延を増やしながら、そしてリトライが追加する負荷に上限を設けてリトライすることです。
これらの障害はリトライし、決してこれらをリトライしない
バックオフの計算をする前に、クライアントは判断テーブルを必要とします。サーバーがすでに無効と判断したリクエストをリトライしても、容量を無駄にし、ログを汚すだけです。一時的な障害をリトライすることこそが、本来の目的です。 これらをリトライする:
| 信号 | 意味 |
|---|---|
| 429 Too Many Requests | レート制限に達しました。間隔を空けて、よりゆっくりと再試行してください。 |
| 502 Bad Gateway | アップストリームのホップが不正な値を返しました。一時的なものであることが多いです。 |
| 503 Service Unavailable | サーバーが過負荷状態にあるか、再起動中です。 |
| 504 Gateway Timeout | アップストリームの依存関係が遅すぎました。 |
| Connection resets, DNS failures, socket timeouts | リクエストが届かなかった可能性があります。 |
504 Gateway Timeoutには特別な注意が必要です。ゲートウェイが待機を諦めたとしても、オリジンはあなたのリクエストを処理していた可能性があります。この違いは、後で冪等性について触れる際に重要になります。 これらは決してリトライしない:
| 信号 | 意味 |
|---|---|
| 400 Bad Request | ペイロードが不正です。次回も不正なままです。 |
| 401 Unauthorized | 認証情報が間違っているか、期限切れです。トークンを更新し、ループさせないでください。 |
| 403 Forbidden | 許可がありません。リトライしても付与されません。 |
| 422 Unprocessable Entity | バリデーションに失敗しました。タイミングではなく、データを修正してください。 |
ルールは次のとおりです。障害がサーバーの状態やネットワークに関する場合はリトライします。障害があなたのリクエストに関する場合はすぐに失敗させます。429はその中間です。リトライ可能ですが、全体的なリクエストレートを改善する必要があるという信号でもあります。これは、リトライループの上流で解決すべきレート制限の問題です。
指数バックオフの計算式とジッターが重要な理由
指数バックオフとは、各リトライが前回よりも長く待機することを意味し、デフォルトでは2倍になります。 ``` delay = base * 2^retry_count ``` ベースが500msの場合、0.5秒、1秒、2秒、4秒、8秒となります。遅延が分単位にならないように上限(例えば30秒)を追加します。 ``` delay = min(cap, base * 2^retry_count) ``` これは「ハンマーリング」の問題を解決しますが、同期の問題は解決しません。もし5,000のクライアントが同時に失敗した場合、単純な指数バックオフでは、その5,000すべてがt=0.5秒、次にt=1秒、次にt=2秒に戻ってきます。それでも波状です。まだ群れであり、ただより丁寧なだけです。 ジッターは遅延をランダム化することで同期を破ります。AWSアーキテクチャブログは、その「指数バックオフとジッターの分析」で数字を計算し、競合するリソースに対する競合クライアントをシミュレートしました。ジッターなしのバックオフでは、依然として呼び出しのクラスター化されたスパイクが発生しました。ゼロから指数上限までのランダムな遅延を選択するフルジッターは、総呼び出し数が最も少なく、完了時間も最短に近い結果をもたらしました。 ``` delay = random_between(0, min(cap, base * 2^retry_count)) ``` この結果は人々を驚かせます。ゼロまでランダム化するのは、きちんと2倍になるスケジュールに比べてずさんに感じられるからです。しかし、クライアントをウィンドウ全体に均等に分散させることが、サーバー負荷を平坦に保つ上でまさに重要です。AWSの分析では、「イコールジッター」(半分固定、半分ランダム)と「非相関ジッター」もテストされました。フルジッターと非相関ジッターが優れており、フルジッターは正しく書くのが最も簡単です。他に測定結果がない限り、これをデフォルトのリトライパターンとして使用してください。
サーバーが指示したらRetry-Afterを尊重する
バックオフは、クライアントがどれくらい待つべきかを推測するものです。時には、サーバーが推測の必要をなくしてくれます。429および503レスポンスのために定義されているRetry-Afterヘッダーは、秒数またはHTTP日付のいずれかを運びます。 ``` HTTP/1.1 429 Too Many Requests Retry-After: 12 ``` このヘッダーが存在する場合、それは計算されたバックオフを上書きします。サーバーはレート制限ウィンドウがいつリセットされるか、またはメンテナンスがいつ終了するかを知っています。あなたの指数スケジュールは知りません。クライアントがRetry-Afterを無視することが、プロバイダーがスロットリングから完全な禁止措置にエスカレートする理由の一つです。それを解析し、尊重し、それでもなお、敵対的またはバグのあるRetry-After: 86400があなたのワーカーを一日中停止させないように、キャップと最大リトライカウントを適用してください。
冪等性:POSTをリトライするための前提条件
以前の504に関する落とし穴がここにあります。GET、PUT、DELETEは契約上冪等です。2回送信してもシステムは同じ状態になります。POSTはそうではありません。もし`POST /v1/payments`がタイムアウトしたが、サーバーがすでに処理していた場合、あなたのリトライは2回目の支払いを作成してしまいます。おめでとうございます、あなたは優れた稼働率で二重請求機を構築しました。 解決策は冪等性キーです。これは、各論理操作でヘッダーとして送信される一意のクライアント生成ID(通常はUUID)です。サーバーは最初の応答とともにキーを保存し、重複した要求に対してその保存された応答を再生します。Stripeの冪等なリクエストはまさにこの方法で機能し、ほとんどの支払いAPIやプロビジョニングAPIがそれに倣っています。 キーを機能させるには2つのルールがあります。 * 同じ操作、同じキー。1つの論理的な支払いのリトライごとに1つのキーを再利用します。新しいユーザーアクションには新しいキーが付与されます。 * 最初の送信前にキーを生成し、リトライループの中では生成しません。そうでなければ、各リトライが新しい操作のように見えてしまい、保護が無効になります。 呼び出すAPIが冪等性キーをサポートしていない場合、非冪等な書き込みを自動的にリトライしないでください。失敗を表面化させ、人間または照合ジョブに判断させましょう。
リトライバジェットとサーキットブレーカー:緊急脱出路
バックオフはリトライが発生するタイミングを決定しますが、発生回数を制限するものではありません。長い障害の間、十分にジッターが適用されたクライアントでさえリトライ負荷を蓄積し、階層化されたリトライは増殖します。たとえば、APIゲートウェイが3回リトライし、サービスクライアントが3回リトライすると、1回のユーザークリックが9回のリクエストになる可能性があります。 損傷を抑えるための2つのメカニズムがあります。 リトライバジェット。「リクエストごとに3回リトライ」ではなく、「リトライは、スライディングウィンドウで測定される、最大10%の追加負荷しか加えない」ように強制します。予算が使い果たされると、失敗はすぐに返されます。これにより、同時に失敗するリクエストがいくつあっても、リトライの増幅は制限されます。LinkerdとEnvoyはどちらもこれをファーストクラスの構成として提供しています。 サーキットブレーカー。ダウンストリームごとの失敗率を追跡します。しきい値を超えると、ブレーカーが開き、ネットワークに触れることなく呼び出しが即座に失敗します。クールダウン後、いくつかのプローブ要求によって、ブレーカーが再び閉じる前に依存関係が回復したかどうかをテストします。バックオフが丁寧に殺到を遅らせるのに対し、ブレーカーはそれをキャンセルします。すべての真剣なリトライ設計は、この2つを組み合わせています。なぜなら、バックオフだけでは、最終的にはすべてのリクエストを送信してしまうからです。
Pythonでの本番環境対応の例
ここに全パターンがまとまっています。リトライ可能なステータスのフィルタリング、フルジッター、Retry-Afterサポート、冪等性キー、そして厳格なリトライ上限です。 ```python import random import time import uuid import requests RETRYABLE = {429, 502, 503, 504} BASE = 0.5 # seconds CAP = 30.0 # ceiling on any single delay MAX_RETRIES = 5 def create_payment(payload): idempotency_key = str(uuid.uuid4()) # one key per logical payment headers = {"Idempotency-Key": idempotency_key} for retry_count in range(MAX_RETRIES + 1): try: resp = requests.post( "https://api.acmepay.com/v1/payments", json=payload, headers=headers, timeout=10, ) if resp.status_code < 400: return resp.json() if resp.status_code not in RETRYABLE: resp.raise_for_status() # 400/401/403/422: fail fast retry_after = resp.headers.get("Retry-After") except (requests.ConnectionError, requests.Timeout): retry_after = None # network fault: fall through to backoff (ネットワーク障害:バックオフへ移行) if retry_count == MAX_RETRIES: raise RuntimeError("payment failed after all retries") # すべてのリトライ後に支払いが失敗しました if retry_after and retry_after.isdigit(): delay = min(CAP, float(retry_after)) else: delay = random.uniform(0, min(CAP, BASE * 2 ** retry_count)) time.sleep(delay) ``` 注目すべき点:キーはループの外で一度だけ生成されます。Retry-Afterは計算されたバックオフよりも優先されますが、それでも上限は尊重されます。リトライ不可のステータスはすぐにエラーを発生させます。JavaScript側では、axios-retryライブラリがretryConditionとretryDelayフックで同じ形式を提供します。判断テーブルは同じままです。
本番環境があなたの代わりにテストする前に、リトライ動作をテストする方法
ほとんどのチームは、一度も失敗ブランチが実行されたことのないリトライコードを出荷しています。ハッピーパスはテストされましたが、503パスは実際の障害時に初めて実行されます。Apidogの2つの機能を使用すれば、より良いことができます。 モックサーバーで障害をシミュレートする。 Apidogのスマートモックでは、/v1/paymentsのようなエンドポイントを定義し、その応答をスクリプト化できます。最初の2回の呼び出しでは503を返し、3回目では200を返すように設定したり、Retry-After: 5とともに429を返したり、クライアントのタイムアウトをトリガーするために15秒の遅延を追加したりできます。クライアントをモックURLに向け、リトライループが各シナリオを処理するのを見てください。本番環境でのインシデントは不要です。 テストシナリオでクライアントの動作をアサートする。 Apidogのテストシナリオは、リクエストをアサーションとタイミングチェックで連結します。不安定なモックに対して発火するシナリオを構築し、呼び出しが最終的に成功すること、合計経過時間が予想されるバックオフの範囲内であること、そしてちょうど1つのリソースが作成されたこと(冪等性キーがその役割を果たしたことを証明する)をアサートします。そのシナリオをCIに組み込めば、リトライロジックは障害が発生するたびにではなく、コミットされるたびにテストされます。 これが「リトライを追加しました」と「レート制限された、半壊状態の依存関係でもクライアントが生き残ることを検証しました」の違いです。Apidogを無料でダウンロードすれば、約10分でクライアントに対して失敗するモックサーバーを実行できます。
FAQ
429をリトライすべきですか?
はい。そしてこれは、サーバーが通常どのようにすべきかを教えてくれる唯一のステータスです。Retry-Afterヘッダーを読み取り、少なくともその期間待機してください。ヘッダーがない場合は、ジッター付き指数バックオフにフォールバックします。また、繰り返される429は、クライアント側のスロットリングやキャッシングでリクエストレートを修正する必要があるという信号として扱い、通常の動作とは見なさないでください。
フルジッターとは何ですか?
フルジッターは、各リトライ遅延をゼロから指数上限までの間で一様ランダムに選択します。`random(0, min(cap, base * 2^n))`。これにより、多数のクライアントからの同期されたリトライの波を防ぎます。AWSのシミュレーションでは、総呼び出し数と完了時間の両方で、単純なバックオフとイコールジッターを上回ったため、AWS SDKではデフォルトとなっています。
POSTリクエストをリトライしても安全ですか?
リクエストが実際に冪等である場合のみです。POSTの場合、これはサーバーが重複排除する冪等性キーを送信することを意味します。それがない場合、タイムアウト後のリトライは、支払い、注文、または記録を重複させる可能性があります。なぜなら、サーバーは失敗したとあなたが考えているリクエストをすでに処理している可能性があるからです。書き込みAPIを呼び出すAIエージェントはこれに常に遭遇します。AIエージェントのエラーリカバリパターンは、ここで説明されているものと同じです:キー付き書き込み、上限付きリトライ、およびサーキットブレーカーです。
何回リトライすべきですか?
3〜5回の試行でほとんどすべての一時的な障害に対応できます。それ以上になると、成功率は横ばいになり、負荷とレイテンシは上昇し続けます。リクエストごとの上限と、全体的なリトライバジェット(例:リトライによって最大10%の追加トラフィックが許可される)を組み合わせることで、完全な障害が発生しても負荷が増大することはありません。依存関係が最後の再試行後も停止したままである場合、それはリトライの領域ではなく、サーキットブレーカーの領域です。
