cd ../back to blog
$Guide//June 4, 2026//7 min read

レート制限・リトライ・バックオフ:AI API のための本番エラー処理

AI API のエラーを本番品質で扱うための実務ガイド。429 と 5xx だけをリトライし、フルジッター付き指数バックオフ、Retry-After の尊重、タイムアウト、冪等性、サーキットブレーカーまでを OpenAI SDK の Python で解説します。

デモは最初の 1 回でうまくいきました。本番はそれ以外の 999,999 回 — トラフィックの急増でレート制限に当たる呼び出し、デプロイの最中に上流の 503 を踏む呼び出し、応答の返らないソケットでハングする 呼び出しです。おもちゃの連携と信頼できる連携を分けるのは、ほぼ全面的に うまくいかない経路の扱い方です。何をリトライし、どれだけ待ち、 いつ諦めるか。ここを誤ると、プロバイダーの一瞬のしゃっくりが自滅的な 障害へと変わります。ワーカー全員が寸分違わぬタイミングでリトライし、 レートリミッターが始めた仕事を最後まで仕上げてしまうのです。

本記事は実務的で本番品質のウォークスルーです。リトライ可能なものだけを リトライするようエラーを分類し、ジッター付きで指数的に バックオフし、Retry-After を尊重し、まともなタイムアウトを 設定し、冪等性でリトライを安全にし、サーキットブレーカーで死んだ依存先を 叩き続けるのをやめます。コードは https://api.brievio.com/v1 に対する OpenAI SDK の Python ですが、ルールはどの HTTP クライアントでも、どの言語でも同じです。

リトライ可能なものだけをリトライする — それ以外は一切しない

AI API のエラー処理で最もよくあるバグは、決して成功しないものを リトライすることです。401(不正なキー)、400(不正なリクエスト)、422(プロンプトが長すぎる)— これらは 決定論的です。2 回目の試行も 1 回目とまったく同じように失敗します。違いは、 いまや 5 回試行し、数秒が経っているという点だけ。さらに悪いことに、本物の バグをリトライループの裏に隠してしまっています。リトライする価値のある 4xx429(レート制限)だけです。これだけは 一時的だからです。

  • リトライする: 429、そして 500 / 502 / 503 / 504 — これらはサーバー側や容量側の 一時的な状態です。
  • リトライする: 接続エラーと読み取りタイムアウト (リクエストがモデルに届いていないか、モデルが閉じたソケットに向けて 応答したかもしれない)。
  • 絶対にリトライしない: それ以外の 4xx 400401403 404422。表に出し、アラートを上げ、 呼び出し側を直してください。

役に立つ直感: リトライとは同じリクエストが違う答えを返すほうに 賭けることです。それが正しいのは、失敗の原因がタイミングや容量にあるとき だけで、リクエストそのものにあるときではありません。

ジッター付き指数バックオフ

失敗がリトライ可能だと分かったら、次の問いはどれだけ待つかです。即座に リトライしても無意味です — 429 を引き起こした状態は 1 ミリ秒後もまだそこにあります。標準的な答えは指数バックオフです。おおよそ 0.5s 待ち、次に 1s2s4s と、試行ごとに倍にしていきます。ただし 長い復旧で永遠に止まらないよう、天井で頭打ちにします。

しかし純粋な指数バックオフには、規模が大きくなると致命的な失敗モードが あります。500 のワーカーが同じ瞬間に一斉にレート制限を食らうと — これはまさにスパイク時に起きることです — 全員が同じ量だけバックオフし、 同じ瞬間にリトライして、2 秒周期でスパイクを再生産します。これが 群衆の殺到(thundering herd)です。これを直すのがジッターです。各待ち時間をランダム化してリトライを散らします。フルジッター(ゼロ からバックオフの天井までの間でランダムな量だけ眠る)は、固定の待ち時間に 小さな乱数を足すよりはるかにうまく群衆を脱同期させます。

retry.py
# 実戦投入できるリトライラッパー。ほぼすべての仕事は次の 2 つのルールが担う:
#   1. リトライ可能なものだけリトライする(429 + 5xx + 接続エラー)。400/401/422 は絶対にしない。
#   2. 指数的にバックオフし、さらにジッターを足す。さもないと全クライアントが足並みを揃えてリトライする。
import random
import time

from openai import OpenAI, APIStatusError, APIConnectionError, APITimeoutError

client = OpenAI(
    api_key="sk-brievio-...",
    base_url="https://api.brievio.com/v1",
    timeout=30,          # リクエストごとの上限 — 下の「タイムアウト」を参照
)

RETRYABLE_STATUS = {429, 500, 502, 503, 504}
MAX_ATTEMPTS = 5
BASE_DELAY = 0.5         # 秒
MAX_DELAY = 20.0         # バックオフに上限をかけ、復旧の遅れで永遠に止まらないようにする

def chat_with_retry(**kwargs):
    for attempt in range(MAX_ATTEMPTS):
        try:
            return client.chat.completions.create(**kwargs)
        except APIStatusError as e:
            # 429 以外の 4xx はあなた側のバグ(不正なパラメータ、不正なキー、長すぎる入力)。
            # リトライしてもレイテンシを浪費するだけ — 即座に失敗させる。
            if e.status_code not in RETRYABLE_STATUS:
                raise
            last_error = e
        except (APIConnectionError, APITimeoutError) as e:
            # ネットワークの瞬断、またはこちらのタイムアウトが発火。読み取りなら安全にリトライできる。
            last_error = e

        if attempt == MAX_ATTEMPTS - 1:
            break

        # フルジッター付きの指数バックオフ: sleep ∈ [0, base * 2**attempt]。
        # フルジッター(「バックオフ + 小さな乱数」ではない)こそが、群衆の殺到を
        # 実際に脱同期させる。ジッター付きバックオフについては AWS Architecture Blog を参照。
        ceiling = min(MAX_DELAY, BASE_DELAY * (2 ** attempt))
        time.sleep(random.uniform(0, ceiling))

    raise last_error

試行回数の上限MAX_ATTEMPTS = 5)と 待ち時間の上限MAX_DELAY)に注目して ください。無制限のリトライこそ、一時的な瞬断が決して捌けないバックログへ 変わる道です。リクエストが処理されるより速く積み上がり、レイテンシが 上昇し、上流の呼び出し側もタイムアウトして自分のリクエストを リトライしはじめます。両方に上限を設け、失敗をバッファに溜め込まず可視に してください。

Retry-After を尊重する — 推測しない

バックオフ曲線は、いつ容量が空くかについての推測です。サーバーが答えを 直接教えてくれるなら、それを使いましょう。429 はしばしば Retry-After ヘッダー(秒数または HTTP 日付)を伴い、本当の リセットウィンドウを反映します。その値だけ眠るほうが、どんな式よりも厳密に 優れています。経験則ではなく真の事実だからです。

retry_after.py
# サーバーが待つべき時間を教えてくれたら、それに従う。429(場合によっては
# 503)は Retry-After ヘッダーを伴う。自作のどんなバックオフ曲線よりこれを尊重するほうがよい。
# 推測ではなく、本当のリセットウィンドウを反映しているからだ。
import email.utils as eut
import time

def retry_delay(resp_headers, attempt, base=0.5, cap=20.0):
    # 1. サーバーの指示を優先する。
    ra = resp_headers.get("retry-after")
    if ra is not None:
        try:
            return float(ra)                       # 秒数形式: "2"
        except ValueError:
            when = eut.parsedate_to_datetime(ra)   # HTTP-date 形式
            return max(0.0, when.timestamp() - time.time())

    # 2. ヘッダーがない? フルジッター付き指数バックオフにフォールバックする。
    import random
    return random.uniform(0, min(cap, base * (2 ** attempt)))

# 補足:
#   - X-RateLimit-Reset を送るプロバイダーもある。同じように扱えばよい。
#   - ごく小さな下限(例: 50ms)を設け、「Retry-After: 0」でホットループに陥らないようにする。
#   - Brievio は 429 で Retry-After を返し、ソケットを黙って止めたりしないので、この
#     経路は実際に到達可能 — シグナルを受けて本当にバックオフできる。

これが機能するのは、ゲートウェイが制限を飲み込んでソケットを 90 秒 止めるのではなく、実際にヘッダーを返す場合だけです。 Brievio は速やかに、はっきりと失敗します — レート制限は ハングした接続ではなく、Retry-After を伴うきれいな 429 として返るので、「サーバーに従う」経路が到達可能になります。 どのコードがどのヘッダーを伴うかも含めた完全なエラー分類は、 エラーリファレンスにあります。

タイムアウト: 誰もテストしない失敗モード

リトライポリシーは、リクエストがそもそも戻ってこなければ無意味です。 タイムアウトのない AI 呼び出しは、いずれハングします — 半開きの接続、 詰まった上流、フローを落としたロードバランサー。デッドラインがなければ、 その 1 リクエストはワーカー、接続、そしてその後ろのあらゆるキューの枠を、 OS が数分後に諦めるまで握り続けます。明示的なリクエストごとのタイムアウト (上の timeout=30)を設定し、詰まった呼び出しを死荷重では なくリトライ可能な APITimeoutError に変えてください。

  • リクエストごとのタイムアウトは単一の試行を区切ります。 ストリーミングでは、意味のある予算は壁時計上の合計ではなく、 最初のトークンまでの時間にチャンク間のアイドルタイムアウトを足したもの です。
  • 全体のデッドラインはリトライ列全体を区切ります。予算 (例: エンドツーエンドで 60s)を追跡し、使い切ったらリトライをやめて ください — あなたを待っている呼び出し側にも、自分のデッドラインが あります。
  • タイムアウトは p50 ではなく、想定 p99 より大きく。 実際のテールレイテンシのすぐ上に設定してください。きつすぎると、もう 少しで成功するはずだった良いリクエストをキャンセルし、せっかちさから 負荷を作り出してしまいます。

冪等性: リトライを安全にする

リトライは微妙な危険を持ち込みます。リクエストがタイムアウトしたとき、 サーバーがそれを処理したかどうかは分かりません — 応答の読み取りは 失敗しましたが、作業自体は完了していたかもしれません。盲目的にリトライ すると、顧客に二重課金したり、通知を 2 回送ったり、重複した行を書いたり しかねません。読み取りは本質的にリトライしても安全です。副作用はそうでは ありません。

防御策は冪等キー(idempotency key)です。論理的な操作に 付与する一意の ID で、サーバー(あるいは自前のハンドラー)が重複を まとめます。純粋な推論呼び出しなら、たいてい気にすべき外部副作用は ありません — ですが、補完が DB 書き込みや決済、外向きのメッセージを 引き起こす瞬間から、論理的な作業単位ごとに安定したキーを生成し、それで 重複排除してください。経験則はこうです。 リトライが 2 回起こりうるなら、必ず起こるものとして設計せよ。

サーキットブレーカー: 死んだ依存先を蹴るのをやめる

バックオフは単一の苦しんでいるリクエストには対処します。しかし持続的な障害には何もしません — 上流が 2 分間ハードダウンすれば、すべての リクエストがリトライの梯子を最後まで上り、最大まで待ち、それでも失敗し、 その間レイテンシとキューの深さが爆発します。サーキットブレーカーはこれを短絡させます。N 回連続で失敗すると「開いて」新しい呼び出しを 即座に失敗させ(あるいはフォールバックに回し)、クールダウンの間そうし続け、 その後 1 本だけプローブを通して復旧を試し、問題なければ再び閉じます。

  • クローズド: 通常稼働。リクエストは流れ、失敗は カウントされます。
  • オープン: しきい値に到達 — 望みのないリトライを 積み上げる代わりに、クールダウンの間は速やかに拒否します。
  • ハーフオープン: クールダウン後、試験的なリクエストを 1 本だけ通します。成功すればブレーカーを閉じ、失敗すれば再び開きます。

ブレーカーをフォールバック経路と組み合わせれば、障害はハードな失敗ではなく 劣化で済みます。ここはゲートウェイが真価を発揮する場面でもあります。Brievio はベンダー横断のフェイルオーバーを行うので、1 つの プロバイダーが落ちても、ブレーカーが開く必要すら生じる前に健全な側へ ルーティングできます。これが現実の信頼性予算にどう収まるかは、 99.95% の SLO をどう設計するかをご覧ください。

リトライのコストについての注記

攻めたリトライ調整をすると請求が心配になります — リトライはどれも 課金対象の呼び出しだろう? と。正直に課金するゲートウェイなら、そうでは ありません。Brievio は失敗した 4xx/5xx の呼び出しには一切課金しません。だから、バックオフを引き起こした 429 も、 リトライで通り過ぎた 503 も無料です。支払うのは、実際に補完を 返した試行に対してだけで、システムが拒否したものには支払いません。つまり、 メーターのペナルティを気にせず信頼性のために試行回数と タイムアウトを調整できます — それでも暴走リトライが予算の心配なら、 AI API の支出に上限をかける方法で 説明しているとおり、支出に直接上限をかけられます。

まとめ

AI API の本番エラー処理は 6 つのルールで、そのすべてを午後いっぱいで 実装できます。

  • 4295xx だけをリトライする。それ以外の 4xx は決してリトライしない — それはあなたのバグが表に 出たものです。
  • フルジッター付きで指数的にバックオフし、試行回数と 待ち時間の両方に上限をかける。
  • サーバーが送ってきたら Retry-After を尊重する — どんな式 にも勝ります。
  • 明示的なリクエストごとのタイムアウトと全体のデッドラインを設定する。 呼び出しを決してハングさせない。
  • 副作用を伴うリトライは冪等キーで安全にする。
  • サーキットブレーカーを追加し、持続的な障害を崩壊ではなく劣化で 済ませる。

どれも特別なものではありません — 稼働し続けるという退屈な約束を守る、 退屈なインフラです。そしてそれが最もよく効くのは、速やかに失敗し、本物の シグナルを表に出し、その失敗に課金しない土台の上です。だからこそ調整は 正直で、しかも無料になります。トラフィックをどこに向けるかまだ選んでいる 最中なら、障害時の信頼性のふるまいは最初にテストすべきことの 1 つです — AI API ゲートウェイの選び方で取り上げています。