深夜 2 時、Slack の通知音で目が覚めて、アラートを確認すると本番の AI エンドポイントが 15 分間ほぼ全滅している——こういう経験、一度はしてみたくないのではないでしょうか。原因は大抵、Google Gen AI API 側の一時的な 503、もしくはこちら側のリトライ暴走による RPM 上限の自滅です。
Antigravity で Python のエージェントや API サーバーを開発していると、最初は「とりあえず動くコード」を書いてしまいがちです。try: ... except: ... でエラーを握りつぶし、失敗したらそのままクライアントにエラーを返す。ローカルでは問題なく動きますが、本番のトラフィックに晒すと、ほんの小さなきっかけで連鎖的に落ちていきます。
本番で生き残るコードに求められるのは、派手な工夫ではなく、地味な設計の積み重ねです。ここではGoogle Gen AI SDK を使った Python サービスを、Antigravity 環境で設計・実装・デプロイしてきた私の運用経験をもとに、「どこでどう壊れるか」「それをどう受け止めるか」を、実際に動くコードと一緒に体系化していきます。公式ドキュメントには書かれていない、本番でだけ露呈する罠にも踏み込みますので、一通り読み終えたころには、自分のプロジェクトに足りない「堅さ」がはっきり見えてくるはずです。
本番で AI API が落ちるパターンを正確に分類する
リトライを書く前にやるべきことは、「自分のコードはどのタイプの失敗で倒れているのか」を把握することです。失敗を一括りに扱うと、本来リトライすべきでないエラーまで無限に叩き続け、コストと信頼性の両方を失います。
Google Gen AI API を叩くときに遭遇する失敗は、大まかに次の 5 種類に整理できます。
一時的なサービス障害 : HTTP 500 / 502 / 503 / 504。数秒から数分で回復します。リトライの対象。
レート制限 : HTTP 429。RPM(Requests Per Minute)もしくは TPM(Tokens Per Minute)上限に到達。Retry-After ヘッダに従ってリトライ。
クライアント側エラー : HTTP 400 / 401 / 403 / 404。リクエストそのものに問題があり、リトライしても直らありません。即時失敗させる。
タイムアウト : 接続・読み取り・総処理時間のいずれかが上限超過。ネットワーク問題か、モデル側の遅延か、切り分けが必要。
コンテンツフィルタ由来の失敗 : FinishReason.SAFETY / FinishReason.RECITATION 等。プロンプトを変えない限り何度叩いても同じ結果なので、リトライ禁止。
このうち、盲目的にリトライしてよいのは最初の 2 つだけです。ところが、現場で見るコードの多くは「except Exception で全部受けて 3 回リトライ」という雑な実装になっています。これだと 400 系のエラーまでリトライしてしまい、ユーザーには遅延と同じエラーが連続で返る、という最悪の体験になります。
失敗を分類するための Exception 階層を先に作る
Antigravity の Agent に「Gen AI SDK のエラーをリトライ可否別に分類する Exception 階層を書いて」と依頼すると、こんな叩き台が得られます。これをプロジェクトの起点にして、拡張していくのが実用的です。
# ai_errors.py — リトライ可否で分岐できる Exception 階層
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
class AIError ( Exception ):
"""AI API 呼び出し全般の基底クラス。"""
class RetryableAIError ( AIError ):
"""一時的な失敗。指数バックオフで再試行してよい。"""
class RateLimitedError ( RetryableAIError ):
"""429。retry_after 秒だけ待つこと。"""
def __init__ (self, message: str , retry_after: Optional[ float ] = None ):
super (). __init__ (message)
self .retry_after = retry_after
class PermanentAIError ( AIError ):
"""リクエストに問題があり、リトライしても直らない。"""
@dataclass
class SafetyBlocked ( PermanentAIError ):
"""コンテンツフィルタで拒否。プロンプトを見直す必要がある。"""
reason: str
def __str__ (self) -> str :
return f "Blocked by safety filter: { self .reason } "
リトライロジックはこの階層を前提にして組み立てます。RetryableAIError とそのサブクラスだけを再試行し、PermanentAIError は即座にクライアントへ返す——これだけで、暴走リトライの 7 割は消えます。私は新しいプロジェクトを始めるとき、ほぼ必ずこの階層から書き始めます。
exponential backoff + jitter をサボらずに実装する
リトライ間隔を固定値(たとえば毎回 1 秒)にしてはいけません。複数のプロセスが同時にリトライすると、すべて同じタイミングでサーバーに波状攻撃を仕掛けてしまい、サーバーが起き上がる瞬間をつぶし続けることになります。これが有名な「thundering herd 問題」です。
解決策は、指数的に伸びる待機時間 と ランダムなジッター(ゆらぎ) を組み合わせることです。AWS の SDK でも Google の公式サンプルでも、この組み合わせが標準になっています。
tenacity を使った堅い実装
tenacity ライブラリは Python でリトライを書くときの事実上のデファクトです。デコレータで宣言的にポリシーを記述できるため、ロジック本体がリトライコードに埋もれずに済みます。
# resilient_client.py — tenacity による exponential backoff + jitter
import asyncio
import random
from typing import Any
import httpx
from tenacity import (
AsyncRetrying,
RetryError,
retry_if_exception_type,
stop_after_attempt,
stop_after_delay,
wait_exponential_jitter,
before_sleep_log,
)
import logging
from ai_errors import (
AIError,
PermanentAIError,
RateLimitedError,
RetryableAIError,
)
logger = logging.getLogger( __name__ )
def _classify_httpx_error (exc: httpx.HTTPStatusError) -> AIError:
"""httpx の HTTPStatusError を自前 Exception にマップする。"""
status = exc.response.status_code
if status == 429 :
# Retry-After は RFC 7231 に従い「秒数」または「HTTP-date」だが、
# Google API は秒数しか返さないため int で受ける前提でよい。
retry_after = exc.response.headers.get( "retry-after" )
return RateLimitedError(
f "Rate limited: { exc.response.text[: 200 ] } " ,
retry_after = float (retry_after) if retry_after else None ,
)
if status in ( 500 , 502 , 503 , 504 ):
return RetryableAIError( f "Transient { status } : { exc.response.text[: 200 ] } " )
return PermanentAIError( f " { status } : { exc.response.text[: 200 ] } " )
async def call_with_retry (
client: httpx.AsyncClient,
request: httpx.Request,
* ,
max_attempts: int = 5 ,
total_budget_sec: float = 20.0 ,
) -> httpx.Response:
"""指数バックオフ付きで Gen AI API を呼ぶ。
- max_attempts: 最大試行回数(初回を含む)
- total_budget_sec: 全試行を合計した上限。超えた時点で打ち切る
- RateLimitedError は Retry-After に従う
- PermanentAIError は即座に投げ直して終わる
"""
async for attempt in AsyncRetrying(
reraise = True ,
stop = stop_after_attempt(max_attempts) | stop_after_delay(total_budget_sec),
wait = wait_exponential_jitter( initial = 1 , max = 8 , jitter = 1.5 ),
retry = retry_if_exception_type(RetryableAIError),
before_sleep = before_sleep_log(logger, logging. WARNING ),
):
with attempt:
try :
response = await client.send(request)
response.raise_for_status()
return response
except httpx.HTTPStatusError as exc:
err = _classify_httpx_error(exc)
if isinstance (err, RateLimitedError) and err.retry_after is not None :
# サーバー指定の待機時間に従う(ジッターは加えない)
logger.warning(
"429 received; sleeping %.2f s per Retry-After" ,
err.retry_after,
)
await asyncio.sleep(err.retry_after + random.uniform( 0 , 0.5 ))
raise RetryableAIError( "Retryable after 429" ) from exc
raise err from exc
except (httpx.ConnectError, httpx.ReadTimeout) as exc:
# ネットワーク層の一時障害もリトライ対象
raise RetryableAIError( f "Network error: { exc } " ) from exc
このコードの重要なポイントは 4 つあります。
まず wait_exponential_jitter(initial=1, max=8, jitter=1.5) で、1 秒・2 秒・4 秒・8 秒と指数的に待機時間が伸び、そこに 0〜1.5 秒のランダムなずれが加わります。複数プロセスが同時に起動していても、リトライのタイミングが自然にばらけます。
次に stop_after_attempt(max_attempts) | stop_after_delay(total_budget_sec) で、「試行回数」と「総時間予算」の両方で打ち切るようにしています。stop_after_delay がないと、1 回のリトライが極端に遅れたときに全体が破綻します。
3 つ目は retry_if_exception_type(RetryableAIError) の使い方です。前節で作った Exception 階層が、ここで効いてきます。PermanentAIError は tenacity が再試行対象から外すため、自然に即時失敗します。
4 つ目は 429 の扱いです。Retry-After ヘッダがあるときは、exponential backoff ではなくサーバーが指定した値に従います。そのあとに自前でちょっとしたジッターを足しているのは、Retry-After が秒単位で丸められているため、同じ瞬間に大量のクライアントが復帰しないようにするためです。
タイムアウトは「1 つ」ではない
「タイムアウトを設定してください」と言われて、httpx.AsyncClient(timeout=30) とだけ書いている人は多いと思います。この書き方にはかなり重大な落とし穴があります。
httpx のタイムアウトは実は 4 種類あります。
connect : TCP 接続確立までの上限
read : ソケットからの単一の読み取りの上限
write : ソケットへの書き込みの上限
pool : コネクションプールからの取得待ちの上限
そして、Gen AI API は長大なプロンプトに対して 20〜60 秒ほど沈黙することが普通にあります。そのとき read だけが走り続けていて、接続自体は健全、というケースが多いのです。
モデル特性に合わせたタイムアウト設計
私が実運用で使っているタイムアウトの設計パターンは次のとおりです。
# timeouts.py — モデル種別に応じたタイムアウト戦略
from dataclasses import dataclass
import httpx
@dataclass ( frozen = True )
class ModelTimeouts :
connect: float
read: float
write: float
pool: float
def to_httpx (self) -> httpx.Timeout:
return httpx.Timeout(
connect = self .connect,
read = self .read,
write = self .write,
pool = self .pool,
)
# レイテンシ特性が違うので、同じ client を使い回さないのが重要
TIMEOUTS = {
# Flash 系は軽量で基本的に速い。read が 30 秒超えたら異常
"gemini-3-flash" : ModelTimeouts( connect = 5 , read = 30 , write = 10 , pool = 2 ),
# Pro 系は長考することがあるので read を緩める
"gemini-3-pro" : ModelTimeouts( connect = 5 , read = 120 , write = 10 , pool = 2 ),
# Thinking モードはさらに余裕を持たせる
"gemini-3-pro-thinking" : ModelTimeouts( connect = 5 , read = 240 , write = 10 , pool = 2 ),
}
def client_for (model: str ) -> httpx.AsyncClient:
t = TIMEOUTS .get(model)
if t is None :
raise ValueError ( f "Unknown model: { model } " )
return httpx.AsyncClient(
timeout = t.to_httpx(),
limits = httpx.Limits( max_connections = 100 , max_keepalive_connections = 20 ),
# HTTP/2 を使うと多重化が効いてレイテンシが下がる
http2 = True ,
)
ここで覚えておきたいのは、「クライアントをモデルごとに分ける」という発想です。Flash 用のコネクションプールで Pro の長い応答を受けると、プールが占拠されて他の Flash リクエストまで連鎖的に詰まります。モデル特性は見た目以上に違うので、プールを分けたほうが安全です。
ちなみに、アプリケーション層の最上位では asyncio.timeout() で「このユーザーリクエスト全体の上限」も別途設けます。これは HTTP クライアントのタイムアウトとは独立した概念で、リトライも含めて「どれだけ待っても諦める」というデッドラインを表現します。
# handler.py — リクエスト全体のデッドラインを設ける
async def handle_user_request (user_input: str , model: str ) -> str :
async with asyncio.timeout( 45 ): # ユーザーを 45 秒以上待たせない
async with client_for(model) as client:
request = _build_request(user_input, model)
response = await call_with_retry(
client, request, max_attempts = 3 , total_budget_sec = 30
)
return response.json()[ "candidates" ][ 0 ][ "content" ][ "parts" ][ 0 ][ "text" ]
外側の 45 秒は、内側のリトライ予算(30 秒)より少し長めに取ります。これでリトライが最後まで走っても、必ずユーザーにはタイムアウト応答が戻るようになります。
サーキットブレーカー — カスケード障害を止める最後の砦
リトライは、呼び出し側から見れば「もう一度試す」という賢い挙動に見えますが、サーバー側から見れば「障害中に追加の負荷をかけに来る厄介な相手」です。API 全体が落ちているときにリトライを積み重ねると、復旧を遅らせるだけでなく、自分の RPM 予算も食いつぶしてしまいます。
ここで登場するのがサーキットブレーカーです。一定時間内に失敗が閾値を超えたら「開く(Open)」状態に入り、以降のリクエストを呼び出す前に 失敗させます。しばらくしたら「半開(Half-Open)」に移り、試験的に 1 件だけ通して成功すれば「閉じる(Closed)」に戻す——そういう 3 状態のステートマシンです。
最小限の非同期サーキットブレーカー
pybreaker のような成熟ライブラリを使うのが本筋ですが、振る舞いを理解するために、まず自分で書いてみるのがおすすめです。
# circuit.py — シンプルな非同期サーキットブレーカー
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Awaitable, Callable, TypeVar
from ai_errors import RetryableAIError
T = TypeVar( "T" )
class State ( Enum ):
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
@dataclass
class CircuitBreaker :
failure_threshold: int = 5 # 連続失敗この数で OPEN
recovery_timeout_sec: float = 30.0 # OPEN 後にこの秒数経過で HALF_OPEN
state: State = State. CLOSED
_failure_count: int = 0
_opened_at: float | None = None
_lock: asyncio.Lock = field( default_factory = asyncio.Lock)
async def call (self, fn: Callable[[], Awaitable[T]]) -> T:
async with self ._lock:
if self .state is State. OPEN :
# 回復時間が経過したか確認
assert self ._opened_at is not None
if time.monotonic() - self ._opened_at >= self .recovery_timeout_sec:
self .state = State. HALF_OPEN
else :
raise RetryableAIError( "Circuit is OPEN — fast-failing" )
try :
result = await fn()
except Exception :
async with self ._lock:
self ._failure_count += 1
if (
self .state is State. HALF_OPEN
or self ._failure_count >= self .failure_threshold
):
self .state = State. OPEN
self ._opened_at = time.monotonic()
raise
else :
async with self ._lock:
self ._failure_count = 0
self .state = State. CLOSED
self ._opened_at = None
return result
このブレーカーを前節の call_with_retry の外側に噛ませると、「サーバーが本格的に倒れている間、自分は無駄打ちをしない」という挙動を実現できます。
# 実際の使用例
breaker = CircuitBreaker( failure_threshold = 5 , recovery_timeout_sec = 30.0 )
async def protected_call () -> str :
async def _do () -> str :
async with client_for( "gemini-3-pro" ) as client:
request = _build_request(user_input, "gemini-3-pro" )
response = await call_with_retry(client, request)
return response.json()[ "candidates" ][ 0 ][ "content" ][ "parts" ][ 0 ][ "text" ]
return await breaker.call(_do)
実装上の注意点を 3 つだけ挙げておきます。
まず、失敗のカウントはリトライの「最終結果」で取るべきです。リトライ途中の失敗まで数えると、ブレーカーがすぐ開いてしまいます。上のコードでは call_with_retry が最後まで失敗したときだけ Exception がブレーカーに到達するので、この点は自然に守られています。
次に、ブレーカーはモデル(や API キー)ごとに分けます。Flash が落ちているときに Pro まで遮断すると、不要に可用性を下げます。
最後に、recovery_timeout_sec は長すぎても短すぎてもいけません。短いと復旧前に再突入、長いと正常化しても使えない時間が続きます。私は初期値 30 秒にして、実際の障害が何秒で復旧しているかをメトリクスで観測し、その 1.5 倍に合わせるようにしています。
ストリーミング応答の途中中断に耐える
Gen AI SDK のストリーミングは生産性を大幅に上げますが、「途中で接続が切れたとき、どこから再開するか」という新しい問題を呼び込みます。100 トークン生成したところでネットワークが落ちた場合、また最初から生成し直すのは、コストの観点でも、ユーザー体験の観点でも最悪です。
部分応答を保持するチェックポイント設計
完全に同じテキストを続きから生成させることは、非決定性のある LLM では技術的に困難です。実用的な妥協点は次の 2 つです。
切断時点までの出力をユーザーに返し、リクエスト全体を「成功」扱いにする 。短絡的ですが、チャット UI なら違和感はありません。
切断時点までの出力をプロンプトに組み込み、「ここから続けて」と指示して残りを生成する 。生成量が膨大な場合に有効です。
後者を実装するときは、生成途中のテキストを必ずチェックポイントとして保持する必要があります。
# streaming.py — 部分応答チェックポイント付きのストリーミング
from dataclasses import dataclass, field
from typing import AsyncIterator
from google import genai
from google.genai import types
@dataclass
class StreamCheckpoint :
produced: str = ""
finished: bool = False
error: Exception | None = field( default = None , repr = False )
async def stream_with_checkpoint (
client: genai.Client, model: str , prompt: str , checkpoint: StreamCheckpoint
) -> AsyncIterator[ str ]:
"""ストリームを返しつつ、生成済みテキストを checkpoint に蓄積する。"""
try :
async for chunk in await client.aio.models.generate_content_stream(
model = model, contents = prompt,
):
text = chunk.text or ""
checkpoint.produced += text
yield text
checkpoint.finished = True
except Exception as exc:
checkpoint.error = exc
raise
async def resume_if_interrupted (
client: genai.Client, model: str , original_prompt: str
) -> str :
cp = StreamCheckpoint()
try :
async for _ in stream_with_checkpoint(client, model, original_prompt, cp):
pass
return cp.produced
except Exception :
# 中断した場合、続きだけ生成させる
if not cp.produced:
raise # 1 トークンも出ていなかったので普通にリトライ
resume_prompt = (
f " { original_prompt }\n\n "
f "[以下は先ほど途中まで生成した内容です。この続きから、"
f "重複なく自然に続けて出力してください。] \n\n{ cp.produced } "
)
cp2 = StreamCheckpoint()
async for _ in stream_with_checkpoint(client, model, resume_prompt, cp2):
pass
return cp.produced + cp2.produced
この設計の本質は、「途中まで出たものを捨てない」ことです。ユーザーに見えている途中経過と、サーバー側で保持しているテキストがずれないよう、同じ checkpoint.produced を両方で参照するように組んでいます。
ストリーミング関連の詳しい実装は別記事の Python で実装する Antigravity ストリーミング API — リアルタイム表示の設計と落とし穴 にもまとめていますので、併せて参考にしてください。
レート制限とコスト予算を「同じ層」で管理する
本番で起きた事故ランキングに必ず入るのが、「リトライ暴走でひと晩のコストが 100 倍になった」という話です。リトライ回数だけでは上限が見えないため、ひとつのリクエストが延々と高価なモデルを叩き続けてしまいます。
これを防ぐには、「呼び出し回数」と「コスト」の両方を同じトークンバケットで管理するのが実用的です。
トークン予算つきレートリミッタ
# budget.py — RPM と JPY の両方で絞るリミッタ
import asyncio
import time
from dataclasses import dataclass
@dataclass
class BudgetBucket :
"""RPM と日次コスト上限を同時に管理するバケット。"""
rpm_limit: int
daily_yen_limit: float
_tokens: float = 0.0
_spent_today_yen: float = 0.0
_last_refill: float = 0.0
_lock: asyncio.Lock = None
def __post_init__ (self):
self ._tokens = float ( self .rpm_limit)
self ._last_refill = time.monotonic()
self ._lock = asyncio.Lock()
async def acquire (self, estimated_yen: float ) -> None :
"""1 リクエスト分の枠と予算を確保する。待機が必要なら待つ。"""
async with self ._lock:
# RPM の補充(1 分で rpm_limit トークンが入る)
now = time.monotonic()
elapsed = now - self ._last_refill
self ._tokens = min (
self .rpm_limit,
self ._tokens + elapsed * ( self .rpm_limit / 60.0 ),
)
self ._last_refill = now
# コスト超過チェックは待っても解決しないので即 raise
if self ._spent_today_yen + estimated_yen > self .daily_yen_limit:
raise RuntimeError (
f "Daily budget exceeded: "
f " { self ._spent_today_yen :.1f } + { estimated_yen :.1f } "
f "> { self .daily_yen_limit :.1f } "
)
# RPM は待てば回復する
while self ._tokens < 1.0 :
sleep_for = ( 1.0 - self ._tokens) * 60.0 / self .rpm_limit
await asyncio.sleep(sleep_for)
now = time.monotonic()
elapsed = now - self ._last_refill
self ._tokens = min (
self .rpm_limit,
self ._tokens + elapsed * ( self .rpm_limit / 60.0 ),
)
self ._last_refill = now
self ._tokens -= 1.0
self ._spent_today_yen += estimated_yen
このバケットは「1 分あたりの呼び出し回数」と「1 日のコスト上限」を同時に管理します。RPM は時間の経過で補充されるので、超過しそうなときは待てばよいのですが、コスト上限は待っても復活しないので即座に失敗させます。この非対称性を明示的に扱うのが大事です。
推定コストはリクエスト前に入力トークン数から算出しておき、応答が戻ったらリトライ後に出力トークン数で補正する設計にしておくと、上限に対する精度が上がります。Antigravity + Upstash Redis でエッジキャッシュとレート制限を作る でも、分散環境でのレート制限の考え方を補足していますので、マルチインスタンス構成の場合はそちらも参照してみてください。
観測性 — 「なぜ失敗したか」を必ず残す
ここまでの仕組みをすべて入れても、障害の原因が「分からない」ままだと再発します。「タイムアウトしました」という 1 行のログだけでは、次に何を直せばよいのか分かりません。
私が最低限記録しているのは次の項目です。
リクエスト ID(ユーザー側の trace_id と紐付け可能にする)
モデル名・プロンプトのハッシュ(内容そのものは PII の観点で残さない)
試行回数と各試行の結果(成功/失敗の種別)
消費トークン数(input / output / thinking)
実測レイテンシ(DNS / connect / TTFB / 総時間)
サーキットブレーカーの状態遷移
推定コスト(円換算)
これらは OpenTelemetry の属性として吐き、Grafana / Tempo / Loki に送るのが今時の標準形です。Antigravity の Agent に「OpenTelemetry のデコレータを作って、Gen AI SDK の呼び出しに被せられるようにして」と頼むと、属性名の命名まで含めて叩き台ができます。詳しい組み方は Antigravity × OpenTelemetry で AI 観測性パイプラインを構築 に別途まとめていますので、あわせてどうぞ。
実運用の感覚として、ダッシュボードは「1 分あたりのエラー種別内訳」「モデル別 p50/p95 レイテンシ」「サーキットブレーカー OPEN 回数」の 3 つを最初に作ると、障害対応の初動速度が大きく変わります。逆にそれ以外は後付けで十分です。
副作用のある処理とリトライ — idempotency の考え方
「読み取りしかしない API ならリトライは安全」——これは正しいのですが、AI API を叩いたあとに、結果を DB に保存したり、外部のメール送信をしたり、決済をしたりする場合は話が変わります。リトライ可能なのはネットワーク層の失敗までで、副作用が絡み始めると「2 回実行されたら困る」問題が出てきます。
私が実際に踏んだ事故の 1 つが、「Gen AI で生成した要約をユーザーに Slack 通知するバッチ」が 429 でリトライしたとき、先に Slack 送信だけ成功していて、2 周目でまた通知が飛んで社内のチャンネルが重複投稿で荒れた——というものです。
この種の問題に対する実用的な処方箋は 2 つです。1 つは、副作用を持つ処理を idempotency key つきで包み、同じ key で 2 回呼ばれたら 2 回目は no-op で返すようにすること。もう 1 つは、AI 呼び出しと副作用を別々のトランザクション境界に切り分け、AI 呼び出しだけをリトライ対象にして、副作用は一度成功したら絶対に再実行しないように制御することです。
# idempotency.py — 重複実行を防ぐ最小セット
import hashlib
from typing import Awaitable, Callable, TypeVar
T = TypeVar( "T" )
class IdempotencyStore :
"""実運用では Redis や DB を使うが、概念だけを示す簡易版。"""
def __init__ (self):
self ._cache: dict[ str , object ] = {}
async def run_once (self, key: str , fn: Callable[[], Awaitable[T]]) -> T:
if key in self ._cache:
return self ._cache[key] # type: ignore[return-value]
result = await fn()
self ._cache[key] = result
return result
def request_idempotency_key (user_id: str , payload: str ) -> str :
return hashlib.sha256( f " { user_id } : { payload } " .encode()).hexdigest()
AI 呼び出しと副作用の分離は、await の粒度を調整するだけで実現できます。下のように「AI 呼び出しのリトライ」と「DB 書き込み」のブロックを分け、DB 書き込みは run_once で保護しておけば、同じユーザーから同じプロンプトでリトライが来ても副作用は 1 回きりに抑えられます。
リトライのテストを「本番と同じ経路」で書く
リトライ・タイムアウト・ブレーカーは、失敗パターンに遭遇したときだけ評価される機能です。つまり、ハッピーパスの単体テストではほぼ何も確認できません。本番相当の故障を、自分でねじ込んで検証する必要があります。
私が最低限書いているテストは次の 4 本です。すべて pytest-asyncio + respx で書けます。
一時的な 503 を 2 回返したあと 200 を返すモックに対して、call_with_retry が成功すること
常に 400 を返すモックに対して、リトライが 0 回のまま即座に失敗すること
常に 429 を返しつつ Retry-After: 2 を返すモックに対して、スリープが実測 2 秒以上あること
5 回連続で 503 を返したあとに、サーキットブレーカーが Open に遷移すること
# test_resilience.py — 本番相当の失敗注入テスト
import pytest
import respx
import httpx
from tenacity import RetryError
from resilient_client import call_with_retry
@pytest.mark.asyncio
@respx.mock
async def test_retries_on_503_then_succeeds ():
route = respx.post( "https://example.test/gen" ).mock(
side_effect = [
httpx.Response( 503 , text = "Service Unavailable" ),
httpx.Response( 503 , text = "Service Unavailable" ),
httpx.Response( 200 , json = { "ok" : True }),
]
)
async with httpx.AsyncClient() as client:
req = client.build_request( "POST" , "https://example.test/gen" )
res = await call_with_retry(client, req, max_attempts = 5 )
assert res.status_code == 200
assert route.call_count == 3
@pytest.mark.asyncio
@respx.mock
async def test_no_retry_on_400 ():
respx.post( "https://example.test/gen" ).mock(
return_value = httpx.Response( 400 , text = "Bad Request" )
)
async with httpx.AsyncClient() as client:
req = client.build_request( "POST" , "https://example.test/gen" )
with pytest.raises( Exception ):
await call_with_retry(client, req, max_attempts = 5 )
テストで発見できないものは本番で発見する羽目になる、というのは古くからの真理です。失敗注入のテストは面倒ですが、一度書いてしまえば、将来のリファクタリングで挙動が変わったことを真っ先に教えてくれます。リトライやブレーカーのような「ふだん働かない機能」こそ、テストで守る価値があります。
よくある間違いと落とし穴
ここまでの実装で、本番で落ちない骨組みは揃いますが、現場でたびたび見る「残り 10% の詰めの甘さ」もいくつか紹介しておきます。
ジッターを忘れる : exponential backoff だけで jitter を入れないパターン。複数インスタンスが同時に起き上がると、最悪の同期リトライが起きます。
全 Exception を RetryableAIError にする : 「とりあえず全部再試行」にすると、プロンプト不備の 400 エラーまで永遠に叩き続けます。クライアント側の問題は即時失敗させてください。
Retry-After を無視する : 429 を普通の 500 と同じように扱うと、サーバーが復帰する前にまた突入します。ヘッダは必ず尊重しましょう。
ユーザーリクエストの総時間制限を忘れる : 内部のリトライ予算は設定しているのに、外側の asyncio.timeout() を入れ忘れると、ユーザーが 5 分待たされることがあります。
コネクションプールを使い回しすぎる : 1 つの AsyncClient をグローバルに共有し、モデルごとに違うタイムアウトが必要なのに同じ設定で使い回すと、長考モデルで他リクエストまで詰まります。
メトリクスを失敗時だけ吐く : 成功ログを間引くと、失敗率の分母が分からずに比率が見えなくなります。成功・失敗どちらも一定割合で残しましょう。
サーキットブレーカーを共有しない : 同一インスタンス内で複数のワーカーが別々にブレーカーを持つと、1 ワーカーだけが先に Open してもほかはまだ叩き続けます。プロセス全体で共有してください。
本番投入前の最終チェックリスト
デプロイする前、私は必ず以下を自分で確認しています。実行した結果が 1 つでも危うかったら、その時点で一旦止めます。
ローカルで httptoolkit などを使い、意図的に 429 / 503 / 接続切断 / 遅延を差し込んで、実装した挙動になるか観察した
デッドライン(asyncio.timeout)はリトライ予算 + α になっているか
サーキットブレーカーは、連続失敗を意図的に起こしたとき、想定どおり Open するか
1 日分のコスト上限を設定し、テストで超過させたときに即座に失敗するか
429 のとき、Retry-After に従っているかをログで確認した
メトリクスダッシュボードに「エラー種別」「モデル別レイテンシ」「OPEN 回数」の 3 枚を用意した
本番の API キーとは別に、故障注入用の鍵を用意して、本番環境の挙動をステージングで試せるようにした
この記事のサーキットブレーカー・リトライ予算・デッドライン設計は、同書の「Handling Overload」章のエッセンスを AI API 向けに具体化したものなので、根本から体系立てて理解したい方にはとても役に立ちます。
全体を振り返って — 明日、自分のコードに入れる最初の一手
ここまで読んでくださった方がすぐにできる具体的なアクションは 1 つだけです。「今日まず、except Exception で全部受けているリトライを、RetryableAIError / PermanentAIError 階層に書き換える」——これです。
1 時間かからない変更ですが、ここを分けるだけで「リトライ暴走」「プロンプト不備の永続ループ」「コストの想定外爆発」の 3 大事故のほとんどが消えます。そこから先の exponential backoff、サーキットブレーカー、コスト予算、観測性は、余裕のあるタイミングで順に足していけば大丈夫です。
並列性やストリーミングを組み合わせた設計の全体像を俯瞰したいときは、Python × Antigravity で非同期並行処理を設計する も合わせて読むと、この記事のパターンが立体的に見えてくるはずです。あなたのサービスが、深夜の呼び出しに煩わされず、静かに働き続ける未来を応援しています。