LLM API のリトライと冪等性を安全に実装する
重複課金や副作用の二重実行なしに LLM API 呼び出しをリトライする方法:どのエラーが再試行可能か、ジッター付きバックオフ、冪等キー、ゲートウェイ層での重複排除。
失敗した LLM API 呼び出しのリトライが安全なのは、ネットワークのせいで二度失敗したように見える場合でも、その呼び出しが高々一度しか実行されないと保証できるときだけです。この危険は巧妙です。リクエストがプロバイダー側で成功したあと、応答が戻り道で失われ、クライアントはタイムアウトを見てリトライし、その結果あなたは二重に課金されるか、副作用が二度発火してしまう。解決策は、常に一緒に扱うべき二つの規律です。リトライする価値のあるエラーだけを、バックオフとジッターを添えてリトライすること。そして、再試行可能な各リクエストを冪等にして、重複が再実行されるのではなく認識され統合されるようにすること。本記事では、その両方をどう実現するかを示します。
これが特に LLM トラフィックで重要な理由は、生成呼び出しがトークンごとに実際のコストを生み、エージェントのワークフローが呼び出しを連鎖させるため、あるステップが重複すると下流の状態が汚染されるからです。「失敗したらとりあえずリトライ」は GET なら問題ありませんが、トークンを生成する POST にとっては、いつ起きてもおかしくない課金インシデントです。
リトライする前に、まずエラーを分類する
すべての失敗をリトライすべきではありません。恒久的なエラーをリトライするのは時間の無駄であり、レート制限下では失敗したリクエストも上限にカウントされるため、むしろ自らに害を及ぼします。失敗を三つのバケツに分けましょう。
| クラス | 例 | 対応 |
|---|---|---|
| 再試行可能な一時的エラー | タイムアウト、接続リセット、500、502、503、429 | バックオフ付きでリトライ |
| 再試行不可なクライアントエラー | 400 不正リクエスト、401/403 認証、404、422 | 即座に失敗させ、エラーを表面化 |
| 曖昧なケース | リクエスト送信後のタイムアウト | 冪等な場合にのみリトライ |
三行目が危険なものです。タイムアウトはプロバイダーがリクエストを処理したかどうかを教えてくれません。応答が届かなかったことを告げるだけです。冪等性なしに曖昧な失敗をリトライすること、それこそが二重課金の起こり方そのものです。だからこの二つのトピックは切り離せません。
リトライループ:バックオフ、ジッター、上限
正しいリトライループは指数関数的にバックオフし、並行するクライアントが同期しないようジッターを加え、試行回数を上限で抑え、上記の再試行可能なクラスだけをリトライします。
import random, time
import httpx
RETRYABLE = {429, 500, 502, 503, 504}
def call_with_retries(payload, key, max_attempts=5):
for attempt in range(max_attempts):
try:
r = httpx.post(
"https://your-gateway.example.com/v1/chat/completions",
json=payload,
headers={"Authorization": "Bearer AVE_GATEWAY_API_KEY",
"Idempotency-Key": key},
timeout=30,
)
except (httpx.TimeoutException, httpx.TransportError):
_sleep(attempt); continue # ambiguous: safe because key is stable
if r.status_code in RETRYABLE:
_sleep(attempt); continue
return r # success or non-retryable client error
raise RuntimeError("exhausted retries")
def _sleep(attempt, base=0.5, cap=15.0):
time.sleep(random.uniform(0, min(cap, base * (2 ** attempt))))
Idempotency-Key が引数として渡され、同一の論理リクエストのあらゆる試行を通じて一定に保たれている点に注目してください。この一点こそが、曖昧なタイムアウトのリトライを安全にしているのです。試行ごとに新しいキーを生成してしまえば、この仕組み全体を台無しにしてしまいます。
冪等キーが実際に果たす役割
冪等キーとは、クライアントが生成する一意な識別子であり——一般的な選択肢は v4 UUID です——Idempotency-Key ヘッダーを通じて状態を変更するリクエストに付与します。サーバーは最初のリクエストの結果をそのキーの下に保存し、同じキーを持つ重複が来たときには、操作を再実行するのではなく保存済みの結果を返します。キーは論理操作ごとに一度生成し、その操作のすべてのリトライで再利用してください。
import uuid
key = str(uuid.uuid4()) # once, before the first attempt
result = call_with_retries(payload, key)
堅牢な実装はフィンガープリントを加えます。すなわち、リクエストボディのハッシュをキーと並べて保存するのです。もし後続のリクエストが同じキーで異なるボディを伴って到着したら、サーバーはそれを拒否します——一般的には HTTP 409 Conflict(idempotency_conflict)を返して——誤ってキャッシュされた結果を黙って返すのではなく。キーには TTL があり(24 時間が典型的なウィンドウ)、期限切れ後はキーは忘れられ、再利用は新しい操作を開始します。
プロバイダーのサポートについての注記:OpenAI は Agentic Commerce のチェックアウトフローで Idempotency-Key を明示的に文書化しており、そこでは同じキーで create や complete の呼び出しを繰り返すと同じ結果が返り、パラメータの不一致は 409 になります。一般的な chat や responses のエンドポイントは、これを主要機能として掲げておらず、本プロジェクトを含む大半のゲートウェイは、現時点で /v1/chat/completions や /v1/responses の呼び出しを自動的に重複排除しません。この不足があるからこそ、冪等性を追加する場所としてゲートウェイが適しています。各アップストリームの挙動にかかわらず、すべてのサービスを一度に覆える唯一のチョークポイントだからです。
ゲートウェイで冪等性を実装する
多くのサービスが一つのゲートウェイを通じてモデルを呼び出すとき、キー付きキャッシュを実装すれば、そのゲートウェイこそがシステム全体のために重複を捕捉できる場所です。これは構築すべきパターンであり、切り替えるだけのスイッチではありません。
- クライアントが
Idempotency-Key: <uuid>付きでリクエストを送る。 - ゲートウェイが短い TTL のストア(Redis が好適)でキーを検索する。
- ミス → アップストリームへ転送し、返す前に
{key → response, fingerprint}を保存する。 - ヒット、同一フィンガープリント → アップストリームを呼ばずに保存済みの応答を返す。
- ヒット、異なるフィンガープリント →
409 Conflictを返す。 - ヒット、なお処理中 →
409を返す(または待つ)ことで、素早い二重送信が二重実行されないようにする。
これを一度構築すれば、「リトライの安全性」は各サービスが再実装するものから、一つの強制された性質へ変わります。また、あなたが引き起こしていない重複、つまりユーザーのダブルクリック、キューによるメッセージの再配信、プロキシによる裏側でのリトライからも保護します。キーと結果を記録し、抑制された重複を不可視にせず、自分のストアで監査できるようにしてください。多数のチャネルにまたがるリトライの嵐も、集中化が役立つもう一つの理由です。同じ問題の同時実行側については LLM API のレート制限とバックプレッシャー を読んでください。
保証について率直な注意点があります。真の「高々一度」が成立するのは、副作用を生む層が協調するときだけです。アップストリーム自体が冪等でなければ、ゲートウェイの重複排除はクライアントから見える重複をまとめられても、レスポンス喪失前にアップストリームがすでに処理したリクエストを取り消すことはできません。キー付きキャッシュは再送に対する強い保護と考え、「厳密に一度だけ」という表現は、アップストリームの冪等キーまたは副作用のアトミックな協調が実際に存在する経路に限ってください。
冪等性とストリーミング
ストリーミングは重複排除を複雑にします。応答が逐次的に配信され、途中で中断されることがあるからです。もしストリームがトークンの半分を送出したあとに切れた場合、素朴なキャッシュに対して同じキーで盲目的にリトライすると、部分的な結果を再生してしまう恐れがあります。より安全なパターンとしては、完了した結果だけをキーの下にキャッシュする、中断されたストリームを結果なしとして扱う(リトライがきれいに再生成するように)、あるいはリクエストを再発行するのではなくストリーム自身の再開メカニズムを使って再接続する、といったものがあります。SSE の再開の詳細はSSE による LLM 応答のストリーミングにあります。
リトライと冪等性のチェックリスト
- エラーを分類済み:一時的/
429/5xxはリトライ、4xxは即座に失敗。 - 曖昧なタイムアウトは、リクエストが冪等な場合にのみリトライする。
- バックオフは指数関数的で、フルジッターと厳格な試行上限を伴う。
- 論理操作ごとに一つの
Idempotency-Key、すべてのリトライで再利用。 - サーバーまたはゲートウェイがキー + フィンガープリントを TTL 付きで保存。
- ボディが不一致な重複は、古い成功ではなく
409を返す。 - 処理中の重複を扱い、二重実行しない。
- ストリーミングのリトライは、部分結果を再生するのではなく再接続または再生成する。
安全に失敗するリトライ予算を選ぶ
リトライはレイテンシと引き換えに成功確率を得ますが、無制限のリトライはシステム全体を引き換えに差し出してしまいます。三つの数字で明示的な予算を設定しましょう。最大試行回数、最大総時間、そして試行ごとのタイムアウトです。予算を使い切ったら、永遠にリトライするのではなく、きれいなエラーを表面化させてください。バックオフを伴って五回失敗したリクエストが六回目に成功することはほぼなく、その試行は他のリクエストが必要とする容量を消費しているのです。
規模が大きくなると、巧妙な失敗モードが現れます。十のサービスがそれぞれ独立にリトライすると、それらを合わせたリトライトラフィックだけでレート制限を超えかねず、それがさらなる 429 を生み、さらに多くのリトライを引き起こします。これが自ら招いたリトライストームです。第一の防御は、総トラフィックに対する割合としてのリトライ予算です。たとえばリトライをリクエストの 10% までに抑えれば、広範な障害が負荷を倍増させることはできません。個別の呼び出しではなくチャネル全体が失敗している場合は、壁へ向かってリトライし続けるのではなくトラフィックを移すべきです。そのサーキットブレーカーとフェイルオーバーの仕組みは LLM API のフェイルオーバー戦略 が担うため、ここでブレーカーのロジックを重複させず、この予算と組み合わせてください。
- 試行回数と総経過時間の両方を制限する。試行回数だけではない。
- 集約リトライに上限を設け、回復トラフィックが障害を増幅できないようにする。
- チャネル単位の持続的な失敗は、盲目的にリトライせずフェイルオーバーへ引き渡す。
可観測性がこのループを閉じます。自分のリトライ率、つまり呼び出し全体に占めるリトライの割合を計測し、増加がインシデントになる前に警告となるようにしてください。この指標は呼び出しログから自分で算出するものであり、リトライ割合の上昇は、予算を引き締める必要があることを最も早く示すシグナルです。
FAQ
リクエストがタイムアウトした場合、処理されたのか、されていないのか?
タイムアウトだけからは判断できません。それは応答が届かなかったことを意味するだけで、作業が行われなかったことを意味しません。曖昧なものとして扱いましょう。リクエストが安定した冪等キーを持ち、重複実行がサーバー側で統合される場合にのみリトライしてください。そうでなければ、その曖昧さを呼び出し元に表面化させます。
リトライごとに新しい冪等キーを生成すべきですか?
いいえ——それが典型的な間違いです。論理操作ごとに一つのキーを生成し、そのすべてのリトライで同一のキーを送ってください。試行ごとの新しいキーは、各リトライを真新しいリクエストのように見せてしまうため、重複が通常どおり実行され、保護をすべて失います。
ほぼ同時に二つの同一リクエストが到着したらどうなりますか?
処理中のケースを明示的に扱いましょう。最初のリクエストが完了する前に、同じキーを持つ二つ目は転送されるのではなく 409 Conflict を受け取る(または最初が完了するまでブロックする)べきです。さもなければ、素早い二重送信がキャッシュを追い越して二度実行されてしまいます。
実際の重複で重複排除を検証する
モデル呼び出しを、Idempotency-Key を引数に取るリトライヘルパーで包み、そのキーを操作ごとに一度生成してください。次に、上で説明したキー付きストアを立ち上げ、意図的な重複を送ります。自分の重複排除ログで、二度目の呼び出しが抑制され、課金が一度だけだったことを確認してください。そのストアが存在するまでは、繰り返されたキーはアップストリームが無視する単なるヘッダーにすぎません。実際の重複で抑制を証明することこそ、唯一意味のあるテストです。