本番環境で安全なリトライを実装する方法
午前2時に支払いAPIが失敗したとき、その原因が一時的なネットワーク障害、レート制限、サーバー停止のどれかによって、リトライが決済を救うか、顧客への二重請求を招くかが決まります。リトライは分散システムで最も一般的なレジリエンスパターンですが、実装を誤ると短時間の障害を長時間の障害へ拡大させます。本記事では、リトライ対象のステータスコード、フルジッター付き指数バックオフ、Retry-After、冪等性キー、リトライバジェット、サーキットブレーカー、そしてApidogを使った障害テストを解説します。
安易なリトライが障害を悪化させる理由
1秒間に1,000リクエストを処理するサービスが5秒間停止し、すべてのクライアントが即座に3回リトライするとします。通常の1,000rpsに加えてリトライが殺到し、サーバーは約4,000rpsを処理することになります。すでに過負荷のサーバーは完全に停止し、クライアントはさらにリトライします。
このフィードバックループはリトライストームと呼ばれます。サーバー復旧時にクライアントが同期して押し寄せる現象は、雷鳴の群れ(thundering herd)です。バックオフなしのリトライは負荷を増幅し、元の障害が解消した後も停止を長引かせます。
よくある設計ミスは次の2つです。
- 遅延がない: 即時リトライで障害中のサーバーに負荷を追加する。
- 固定遅延: すべてのクライアントが1秒待つため、同時に再接続する。
解決策は「リトライしない」ことではありません。リトライ対象を選び、ランダムな遅延を増やし、追加負荷に上限を設けることです。
リトライする障害、リトライしない障害
バックオフを計算する前に、リトライ判断のテーブルを用意します。サーバーが無効と判断したリクエストを再送しても、容量を消費し、ログを汚すだけです。
リトライする信号
| 信号 | 意味 |
|---|---|
429 Too Many Requests |
レート制限に達した。間隔を空けて再試行する。 |
502 Bad Gateway |
アップストリームが不正な応答を返した。一時的なことが多い。 |
503 Service Unavailable |
サーバーが過負荷、または再起動中。 |
504 Gateway Timeout |
アップストリームの依存関係が遅すぎた。 |
| Connection reset、DNS failure、socket timeout | リクエストがサーバーに届かなかった可能性がある。 |
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秒
遅延が長くなりすぎないよう、上限を設定します。
delay = min(cap, base * 2^retry_count)
たとえば上限を30秒にします。
ただし、これだけでは同期の問題は解決しません。5,000台のクライアントが同時に失敗すると、全クライアントが0.5秒後、1秒後、2秒後に再接続します。負荷の波が発生する点は変わりません。
そこで、待機時間をランダム化するジッターを加えます。AWSの「指数バックオフとジッターの分析」では、競合するクライアントをシミュレーションし、ジッターなしでは呼び出しがクラスター化することを示しています。フルジッターは、呼び出し回数を抑えながら完了時間も短くできます。
delay = random_between(0, min(cap, base * 2^retry_count))
ゼロから上限まで分散させることで、クライアントを待機ウィンドウ全体に均等に配置できます。
AWSの分析では、次の方式も比較されています。
- イコールジッター:半分を固定、半分をランダム化
- 非相関ジッター
- フルジッター
フルジッターと非相関ジッターが優れ、実装が簡単なフルジッターをデフォルトにしやすい結果でした。別の測定結果がない限り、フルジッターを使うのがよいでしょう。
Retry-Afterを尊重する
バックオフはクライアント側の推測です。一方、サーバーはレート制限の解除時刻やメンテナンス終了時刻を把握しています。
429および503では、サーバーがRetry-Afterヘッダーを返すことがあります。値は秒数またはHTTP日付です。
HTTP/1.1 429 Too Many Requests
Retry-After: 12
ヘッダーが存在する場合は、計算したバックオフより優先します。ただし、次の制限は必ず適用してください。
- 最大待機時間の上限
- 最大リトライ回数
- 不正または極端に大きい値への防御
たとえばRetry-After: 86400を無制限に受け入れると、ワーカーが1日停止する可能性があります。
POSTをリトライする前提条件:冪等性
GET、PUT、DELETEは契約上、冪等な操作です。同じリクエストを複数回送っても、最終的な状態は同じになります。
一方、POSTは通常、冪等ではありません。
POST /v1/payments
このリクエストがタイムアウトしても、サーバー側では決済が完了している可能性があります。その状態でリトライすると、2回目の支払いが作成され、二重請求につながります。
解決策は冪等性キーです。各論理操作に対してクライアントがUUIDなどの一意なIDを生成し、ヘッダーで送信します。サーバーは最初の応答とキーを保存し、同じキーの重複リクエストには保存済みの応答を返します。Stripeの冪等なリクエストもこの方式です。
重要なルールは2つあります。
- 同じ操作には同じキーを使う。 1回の決済に対するすべてのリトライで同じキーを再利用する。
- 最初の送信前にキーを生成する。 リトライループ内で生成すると、毎回新しい操作として扱われる。
呼び出し先が冪等性キーをサポートしていない場合、非冪等な書き込みを自動リトライしてはいけません。失敗を返し、人間または照合ジョブに判断させます。
リトライバジェットとサーキットブレーカー
バックオフは「いつ」リトライするかを決めますが、「何回」リトライするか、全体にどれだけ負荷を追加するかまでは制限しません。
また、APIゲートウェイが3回、サービスクライアントも3回リトライすると、ユーザーの1クリックが最大9リクエストに増えることがあります。
リトライバジェット
「リクエストごとに3回」ではなく、次のように追加負荷全体で制限します。
スライディングウィンドウ内で、リトライによる追加トラフィックを最大10%に抑える。
予算を使い切ったら、以降の失敗は即座に返します。これにより、同時障害が発生してもリトライの増幅を制限できます。LinkerdとEnvoyは、リトライバジェットを標準機能として提供しています。
サーキットブレーカー
サーキットブレーカーは、依存先ごとの失敗率を監視します。しきい値を超えるとブレーカーを開き、ネットワークへ接続せずに即座に失敗させます。
クールダウン後は少数のプローブリクエストを送り、依存先が復旧していればブレーカーを閉じます。
- バックオフ:リトライを遅らせる
- サーキットブレーカー:リトライを止める
長時間の障害に備えるなら、両方を組み合わせます。バックオフだけでは、最終的にすべてのリクエストを送信してしまうためです。
Pythonで実装する本番向けリトライ
次の例では、以下をまとめて実装しています。
- リトライ可能なステータスのフィルタリング
- フルジッター
Retry-After- 冪等性キー
- 最大リトライ回数
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 # ネットワーク障害:バックオフへ移行
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)
実装上のポイントは次のとおりです。
- 冪等性キーはループの外で1回だけ生成する。
-
Retry-Afterは計算したバックオフより優先する。 - サーバー指定の待機時間にも上限を適用する。
- リトライ不可のステータスはすぐに失敗させる。
-
Retry-AfterのHTTP日付形式を使う場合は、その形式も解析する。
JavaScriptでは、axios-retryのretryConditionとretryDelayフックで同じ設計を実装できます。ステータスコードの判断テーブルは変わりません。
本番障害の前にリトライをテストする
ハッピーパスだけをテストし、503やタイムアウトの分岐を本番障害で初めて実行するチームは少なくありません。Apidogを使えば、失敗シナリオを事前に再現できます。
モックサーバーで障害を再現する
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のシミュレーションでは、単純なバックオフやイコールジッターより、総呼び出し数と完了時間の両方で優れた結果になりました。
POSTリクエストをリトライしても安全ですか?
実際に冪等である場合だけ安全です。通常のPOSTでは、サーバーが重複排除できる冪等性キーを送信してください。
キーがなければ、タイムアウト後のリトライによって支払い、注文、記録などが重複する可能性があります。書き込みAPIを呼び出すAIエージェントでも同じ問題が起こります。必要なパターンは、キー付き書き込み、上限付きリトライ、サーキットブレーカーです。
何回リトライすべきですか?
多くの一時障害には、3〜5回の試行で対応できます。それ以上では成功率が伸びにくい一方、負荷とレイテンシは増え続けます。
リクエストごとの最大回数に加えて、リトライによる追加トラフィックを最大10%程度に制限する全体バジェットも設定しましょう。最後のリトライ後も依存先が停止しているなら、それはリトライではなくサーキットブレーカーで処理すべき状態です。
Top comments (0)