DEV Community

Cover image for APIリトライロジックと指数関数的バックオフ:効果的なパターン
Akira
Akira

Posted on Originally published at apidog.com

APIリトライロジックと指数関数的バックオフ:効果的なパターン

本番環境で安全なリトライを実装する方法

午前2時に支払いAPIが失敗したとき、その原因が一時的なネットワーク障害、レート制限、サーバー停止のどれかによって、リトライが決済を救うか、顧客への二重請求を招くかが決まります。リトライは分散システムで最も一般的なレジリエンスパターンですが、実装を誤ると短時間の障害を長時間の障害へ拡大させます。本記事では、リトライ対象のステータスコード、フルジッター付き指数バックオフ、Retry-After、冪等性キー、リトライバジェット、サーキットブレーカー、そしてApidogを使った障害テストを解説します。

今すぐ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
Enter fullscreen mode Exit fullscreen mode

ベースを500msにすると、待機時間は次のようになります。

0.5秒、1秒、2秒、4秒、8秒
Enter fullscreen mode Exit fullscreen mode

遅延が長くなりすぎないよう、上限を設定します。

delay = min(cap, base * 2^retry_count)
Enter fullscreen mode Exit fullscreen mode

たとえば上限を30秒にします。

ただし、これだけでは同期の問題は解決しません。5,000台のクライアントが同時に失敗すると、全クライアントが0.5秒後、1秒後、2秒後に再接続します。負荷の波が発生する点は変わりません。

そこで、待機時間をランダム化するジッターを加えます。AWSの「指数バックオフとジッターの分析」では、競合するクライアントをシミュレーションし、ジッターなしでは呼び出しがクラスター化することを示しています。フルジッターは、呼び出し回数を抑えながら完了時間も短くできます。

delay = random_between(0, min(cap, base * 2^retry_count))
Enter fullscreen mode Exit fullscreen mode

ゼロから上限まで分散させることで、クライアントを待機ウィンドウ全体に均等に配置できます。

AWSの分析では、次の方式も比較されています。

  • イコールジッター:半分を固定、半分をランダム化
  • 非相関ジッター
  • フルジッター

フルジッターと非相関ジッターが優れ、実装が簡単なフルジッターをデフォルトにしやすい結果でした。別の測定結果がない限り、フルジッターを使うのがよいでしょう。

Retry-Afterを尊重する

バックオフはクライアント側の推測です。一方、サーバーはレート制限の解除時刻やメンテナンス終了時刻を把握しています。

429および503では、サーバーがRetry-Afterヘッダーを返すことがあります。値は秒数またはHTTP日付です。

HTTP/1.1 429 Too Many Requests
Retry-After: 12
Enter fullscreen mode Exit fullscreen mode

ヘッダーが存在する場合は、計算したバックオフより優先します。ただし、次の制限は必ず適用してください。

  • 最大待機時間の上限
  • 最大リトライ回数
  • 不正または極端に大きい値への防御

たとえばRetry-After: 86400を無制限に受け入れると、ワーカーが1日停止する可能性があります。

POSTをリトライする前提条件:冪等性

GETPUTDELETEは契約上、冪等な操作です。同じリクエストを複数回送っても、最終的な状態は同じになります。

一方、POSTは通常、冪等ではありません。

POST /v1/payments
Enter fullscreen mode Exit fullscreen mode

このリクエストがタイムアウトしても、サーバー側では決済が完了している可能性があります。その状態でリトライすると、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)
Enter fullscreen mode Exit fullscreen mode

実装上のポイントは次のとおりです。

  • 冪等性キーはループの外で1回だけ生成する。
  • Retry-Afterは計算したバックオフより優先する。
  • サーバー指定の待機時間にも上限を適用する。
  • リトライ不可のステータスはすぐに失敗させる。
  • Retry-AfterのHTTP日付形式を使う場合は、その形式も解析する。

JavaScriptでは、axios-retryretryConditionretryDelayフックで同じ設計を実装できます。ステータスコードの判断テーブルは変わりません。

本番障害の前にリトライをテストする

ハッピーパスだけをテストし、503やタイムアウトの分岐を本番障害で初めて実行するチームは少なくありません。Apidogを使えば、失敗シナリオを事前に再現できます。

モックサーバーで障害を再現する

Apidogのスマートモックで、/v1/paymentsのようなエンドポイントを定義し、応答をシナリオごとに設定します。

  • 最初の2回は503、3回目は200
  • Retry-After: 5付きの429
  • クライアントのタイムアウトを発生させる15秒の遅延

クライアントをモックURLへ向け、各ケースでリトライループが期待どおり動作するかを確認します。

テストシナリオで動作をアサートする

Apidogのテストシナリオでは、リクエストをアサーションやタイミングチェックと連結できます。次の項目を検証しましょう。

  1. 最終的にリクエストが成功すること
  2. 合計経過時間がバックオフの想定範囲内であること
  3. 冪等性キーによってリソースが1つだけ作成されること

シナリオをCIに組み込めば、リトライロジックを障害発生時ではなく、コミットごとに検証できます。

「リトライを追加した」だけでは不十分です。レート制限中や半壊状態の依存先に対しても、クライアントが安全に動作することを証明する必要があります。Apidogを無料でダウンロードすれば、約10分で失敗するモックサーバーを用意できます。

FAQ

429をリトライすべきですか?

はい。ただし、サーバーが返した唯一の明確な指示として扱ってください。

Retry-Afterを読み取り、少なくとも指定時間待ちます。ヘッダーがなければ、フルジッター付き指数バックオフへフォールバックします。

繰り返し429が発生する場合は、通常動作と見なしてはいけません。クライアント側のスロットリングやキャッシュで、リクエストレート自体を下げる必要があります。

フルジッターとは何ですか?

各リトライの遅延を、ゼロから指数上限までの一様乱数で決める方式です。

random(0, min(cap, base * 2^n))
Enter fullscreen mode Exit fullscreen mode

多数のクライアントが同時に再試行する波を防ぎます。AWSのシミュレーションでは、単純なバックオフやイコールジッターより、総呼び出し数と完了時間の両方で優れた結果になりました。

POSTリクエストをリトライしても安全ですか?

実際に冪等である場合だけ安全です。通常のPOSTでは、サーバーが重複排除できる冪等性キーを送信してください。

キーがなければ、タイムアウト後のリトライによって支払い、注文、記録などが重複する可能性があります。書き込みAPIを呼び出すAIエージェントでも同じ問題が起こります。必要なパターンは、キー付き書き込み、上限付きリトライ、サーキットブレーカーです。

何回リトライすべきですか?

多くの一時障害には、3〜5回の試行で対応できます。それ以上では成功率が伸びにくい一方、負荷とレイテンシは増え続けます。

リクエストごとの最大回数に加えて、リトライによる追加トラフィックを最大10%程度に制限する全体バジェットも設定しましょう。最後のリトライ後も依存先が停止しているなら、それはリトライではなくサーキットブレーカーで処理すべき状態です。

Top comments (0)