個人開発で運営している壁紙アプリの裏側で Gemini を回していると、429 RESOURCE_EXHAUSTED は珍しいエラーではありません。問題は、この 429 が二種類あることに長いあいだ気づいていなかったことでした。ひとつは「同じ秒に投げすぎた」一過性のレート制限で、数百ミリ秒待てば素直に通ります。もうひとつは「このプロジェクトの今月の予算をもう使い切った」枯渇で、こちらは何秒待っても、何回叩いても、月が変わるまで通りません。
両方を同じ指数バックオフで処理していると、後者のときにリトライ層が静かに暴れます。1リクエストにつき7回まで再試行する設定なら、枯渇したプロジェクトに対してアプリが延々と7倍の無駄打ちを続け、ユーザーから見れば「読み込みが異常に遅いだけのアプリ」になります。AdMob の広告収益で回している無料アプリだと、この遅延はそのまま離脱につながります。
2026年6月26日に Project Spend Caps が一般提供になり、プロジェクト単位で月間ドル上限を設定できるようになりました。これは費用を構造的に止められる嬉しい更新ですが、同時に「上限に当たった 429」を本番で踏む確率を確実に上げます。つまり、429 を一律でリトライする設計は、いまこそ見直しどきです。プロジェクト構成そのものでの分離はSpend Cap の影響範囲をティア別に分ける設計で扱っているので、本記事はリクエスト時の縮退に絞ります。
429 を「待てば直る」と「待っても無駄」に二分する
最初にやるべきは、リトライ層に渡る前に 429 を分類することです。判断材料は大きく3つあります。
ひとつ目は、エラーレスポンスに含まれる google.rpc.RetryInfo です。サーバーが「この時間だけ待ってから再試行してよい」と明示している場合、retryDelay フィールドが入ってきます。これが付いている 429 は、設計上リトライしてよいレート制限だと解釈できます。
ふたつ目は QuotaFailure の詳細で、どのクォータ次元(リクエスト毎分・トークン毎分など)に当たったかが分かります。秒・分単位で回復するクォータなら待てば直りますが、日次や月次の上限に当たっているなら、待ち時間の単位がまるで違います。
みっつ目が、自分でしか持っていない情報、つまり自前の月次支出ゲートです。これが最も重要です。Spend Cap に当たったかどうかを API のエラー本文だけから確実に判定しようとすると、エラー形状の細部に依存した脆い実装になります。代わりに、自分の側で「今月いくら使ったか」を概算で持っておき、その数字を分類の主軸に据えます。API はあくまで補助信号として使います。
| 信号 | 意味 | リトライ判断 |
|---|---|---|
| RetryInfo.retryDelay あり | サーバー指定の待機後に回復見込み | リトライ可(指定秒だけ待つ) |
| QuotaFailure が分単位クォータ | RPM/TPM 超過。すぐ回復 | リトライ可(バックオフ) |
| 自前の月次支出ゲートが上限超過 | 今月の予算を使い切った可能性大 | リトライ不可(縮退へ) |
| RetryInfo なし・原因不明の枯渇が連続 | 判別不能だが回復していない | 保守的に遮断(ブレーカーを開く) |
ここでの設計判断は「迷ったら叩かない」です。リトライして失われるのは時間とわずかなレイテンシ予算ですが、枯渇しているプロジェクトを叩き続けて得られるものは何もありません。
分類層を実装する
Gemini の Python SDK(google-genai)でエラーを受け、上記の信号を読み取る分類器を組みます。SDK のバージョンによって例外の属性名は揺れるため、特定の属性に依存せず、防御的に取り出すのがコツです。
# pip install google-genai
from dataclasses import dataclass
from enum import Enum
import json
import re
class Verdict(Enum):
RETRYABLE = "retryable" # 待てば直る(バックオフ可)
TERMINAL = "terminal" # 今月は無駄(縮退へ)
UNKNOWN = "unknown" # 判別不能(保守的に遮断)
@dataclass
class Classification:
verdict: Verdict
retry_after_s: float | None # サーバー指定の待機秒(あれば)
reason: str
def _extract_details(err) -> dict:
"""例外から構造化詳細を防御的に取り出す。SDK 差異を吸収する。"""
# google-genai の APIError は .code / .status / .details を持つことが多いが、
# バージョン差があるため getattr と文字列フォールバックで拾う。
payload = {}
for attr in ("details", "response_json", "args"):
val = getattr(err, attr, None)
if isinstance(val, dict):
payload = val
break
if isinstance(val, (list, tuple)) and val and isinstance(val[0], dict):
payload = val[0]
break
if not payload:
# 最後の手段:文字列化した本文から JSON 片を拾う
text = str(getattr(err, "message", "") or err)
m = re.search(r"\{.*\}", text, re.DOTALL)
if m:
try:
payload = json.loads(m.group(0))
except json.JSONDecodeError:
payload = {}
return payload
def _retry_delay_seconds(details: dict) -> float | None:
"""google.rpc.RetryInfo の retryDelay(例 "5s")を秒に変換する。"""
error = details.get("error", details)
for d in error.get("details", []):
t = d.get("@type", "")
if "RetryInfo" in t:
raw = d.get("retryDelay", "")
m = re.match(r"(\d+(?:\.\d+)?)s", str(raw))
if m:
return float(m.group(1))
return None
def _quota_dimension(details: dict) -> str | None:
"""QuotaFailure からクォータ ID(分単位か否かの手がかり)を取り出す。"""
error = details.get("error", details)
for d in error.get("details", []):
if "QuotaFailure" in d.get("@type", ""):
for v in d.get("violations", []):
qid = v.get("quotaId") or v.get("subject") or ""
if qid:
return qid
return None
def classify_429(err, monthly_budget_exhausted: bool) -> Classification:
"""429 を3値に分類する。monthly_budget_exhausted は自前の支出ゲート由来。"""
details = _extract_details(err)
delay = _retry_delay_seconds(details)
qid = _quota_dimension(details) or ""
# 自前ゲートが「今月もう無理」と言っているなら、それを最優先で信じる
if monthly_budget_exhausted:
return Classification(Verdict.TERMINAL, None, "monthly spend gate exhausted")
# サーバーが待機秒を指定 → レート制限。素直に待つ
if delay is not None:
return Classification(Verdict.RETRYABLE, delay, f"server RetryInfo={delay}s")
# 分単位クォータ(PerMinute 等)に当たっている → 待てば回復
if re.search(r"(per[-_ ]?minute|PerMinute|RPM|TPM)", qid, re.IGNORECASE):
return Classification(Verdict.RETRYABLE, None, f"per-minute quota: {qid}")
# 日次・月次・プロジェクトのクォータ枯渇 → 待っても基本直らない
if re.search(r"(per[-_ ]?day|PerDay|monthly|project)", qid, re.IGNORECASE):
return Classification(Verdict.TERMINAL, None, f"long-window quota: {qid}")
# RetryInfo もクォータ次元も読めない枯渇 → 判別不能。保守的に扱う
return Classification(Verdict.UNKNOWN, None, "no RetryInfo, unknown quota")ポイントは、monthly_budget_exhausted という自前の真偽値を最優先で信用していることです。なぜなら、これは推測ではなく自分の手元の記録に基づく事実だからです。API のエラー形状は将来変わり得ますが、「今月の概算支出が上限に達した」という判定は自分のコードが握っています。Spend Cap 時代の堅牢さは、ここをサーバー任せにしないことから来ます。