429 のリトライは SDK の既定に任せておけば足りると思い込んだまま、自分のサービスから API を呼ぶコードを書いておりました。ふと、既定が何回で、どれくらい待つのかを説明できないことに気づいて、SDK のソースを開きました。
_calculate_retry_timeout を読み進めて、手が止まりました。retry-after が付いているときは、値の大きさを見ずに「言われたとおりに待つ」と書かれています。では 120 秒と言われたら、本当に 120 秒止まるのかもしれません。私の思い込みを確かめるために走らせた記録を、書き残します。
最初にお伝えしたいのは、結論です。SDK(Python 1.13.0)の既定は、再試行 2 回、待ちは 0.5 秒から始まる指数バックオフ、そして retry-after は上限なしでそのまま従う、でした。個人開発で自分のサービスから API を呼ぶ側としては、この「上限なし」を知らないまま本番に出すのが、いちばん怖いところです。
検証の条件(本物の API ではありません)
先に範囲を明記します。ここでの測定は、手元の模擬サーバーを相手にしたものです。Anthropic の API を実際に呼んではいません。確かめたのは「SDK が、こういう応答を受けたとき、何回・どれだけ待って再試行するか」というクライアント側の挙動だけです。
- SDK: Python 版
anthropic1.13.0 - 環境: Python 3.10、Linux
- 相手: 標準ライブラリだけで書いた模擬サーバー(末尾に全文)。リクエストごとに時刻を記録し、ヘッダーで指定したシナリオどおりに 429・529・400 などを返します
- 実 API が
retry-afterを付けるかどうかは、公式ドキュメントの記述に従います(後述)
12 通りを走らせた結果
ログは次のとおりです(端末の出力をそのまま載せています)。
429 常時 / 既定(max_retries=2) 試行=3 結果=RateLimitError 間隔(s)=[0.48, 0.98] 合計=1.47s
429 常時 / max_retries=0 試行=1 結果=RateLimitError 間隔(s)=[] 合計=0.00s
429 常時 / max_retries=5 試行=6 結果=RateLimitError 間隔(s)=[0.45, 0.78, 1.98, 3.22, 7.92] 合計=14.35s
429 retry-after: 2 試行=2 結果=ok 間隔(s)=[2.0] 合計=2.03s
429 retry-after-ms: 300 試行=2 結果=ok 間隔(s)=[0.3] 合計=0.31s
429 retry-after: 120 (sleep を記録) 試行=2 結果=ok 間隔(s)=[0.0] 合計=0.00s sleep要求=[120.0]
529 overloaded を 2 回 試行=3 結果=ok 間隔(s)=[0.39, 0.92] 合計=1.31s
400 invalid_request 試行=1 結果=BadRequestError 間隔(s)=[] 合計=0.00s
500 + x-should-retry: false 試行=1 結果=InternalServerError 間隔(s)=[] 合計=0.00s
400 + x-should-retry: true 試行=2 結果=ok 間隔(s)=[0.47] 合計=0.47s
接続切断 試行=3 結果=APIConnectionError 間隔(s)=[0.38, 0.99] 合計=1.37s
応答 3 秒 / timeout=1 試行=3 結果=APITimeoutError 間隔(s)=[1.49, 1.94] 合計=4.43s読み取れた点を表にします。
| 状況 | 試行回数 | 待ち | わかったこと |
|---|---|---|---|
| 429 が続く / 既定 | 3 | 0.48 秒、0.98 秒 | 再試行は 2 回まで。合計 1.5 秒で諦める |
| 429 が続く / max_retries=0 | 1 | なし | 再試行しない |
| 429 が続く / max_retries=5 | 6 | 0.45、0.78、1.98、3.22、7.92 秒 | 待ちは倍々で、上限は 8 秒前後。合計 14 秒 |
| 429 + retry-after: 2 | 2 | 2.0 秒 | ヘッダーの値どおりに待つ |
| 429 + retry-after-ms: 300 | 2 | 0.3 秒 | ミリ秒版のヘッダーも読む |
| 429 + retry-after: 120 | 2 | 120 秒を要求 | 値に上限をかけず、そのまま待とうとする |
| 529 overloaded を 2 回 | 3 | 0.39、0.92 秒 | 5xx は再試行の対象 |
| 400 invalid_request | 1 | なし | 再試行しない |
| 500 + x-should-retry: false | 1 | なし | サーバーの指示が優先される |
| 400 + x-should-retry: true | 2 | 0.47 秒 | 400 でも、指示があれば再試行する |
| 接続切断 | 3 | 0.38、0.99 秒 | 接続エラーも再試行の対象 |
| 応答 3 秒 / timeout=1 | 3 | 1.49、1.94 秒 | タイムアウトも再試行。合計 4.4 秒かかる |
太字の行が、冒頭で気づいた点です。120 秒の行だけは、実際に 120 秒待つと検証が進まないため、time.sleep を差し替えて「SDK が要求した待ち時間」を記録しました。要求値は 120.0 秒でした。待った時間ではなく要求値だと、お断りしておきます。
ソースで裏を取ると、判断はこの順です。
x-should-retryヘッダーがtrueかfalseなら、それに従います。- なければ、408・409・429・500 以上のステータスを再試行します。
- 待ち時間は
retry-after-ms、retry-afterの順に読み、あればその値をそのまま使います。 - なければ、0.5 秒から倍々にして 8 秒で頭打ちにし、最大 25% を引く揺らぎをかけます。
上限到達の 429 は、待っても通りません
もうひとつ、ドキュメントを読み直して気づいた点があります。公式の rate limits のページには、利用上限(spend limit)に達したときの 429 は retry-after が付かず、SDK の自動リトライを含め、アクセスが戻るまで失敗し続ける、と書かれています。Messages API では error.details.error_code が enforced_spend_limit_reached で見分けられます。
この種類の 429 を模擬サーバーに返させて既定の SDK で受けると、3 回試して 1.25 秒を使い、結局同じ RateLimitError で終わりました。1 回の呼び出しなら些細です。ただ、夜間のバッチが数千件をこの調子で回すと、無駄な往復だけが積み上がります。
直し方: 待ち時間と総時間に上限を持つ呼び出し口
SDK の再試行を切って、再試行を自分で握る形にしました。持たせたのは、次の 3 つの線引きです。
- 1 回の待ちには上限を置きます(
retry-afterが大きくても、その上限までしか待ちません)。 - 総時間の予算を置きます(予算を超える待ちは、待たずに諦めます)。
- 上限到達の 429 は、再試行せずにすぐ返します。
"""SDK の自動リトライを切り、待ち時間の上限と総時間の予算を自分で持つ呼び出し口。"""
import time, random, anthropic
RETRYABLE = (408, 409, 429, 500, 502, 503, 504, 529)
def call_with_budget(client, *, budget_s=20.0, max_attempts=4, wait_cap_s=8.0, **kwargs):
c = client.with_options(max_retries=0) # 自動リトライを無効化し、再試行はここで握る
deadline = time.monotonic() + budget_s
for attempt in range(1, max_attempts + 1):
try:
return c.messages.create(**kwargs)
except anthropic.APIStatusError as e:
last = e
body = getattr(e, "body", None) or {}
code = (((body.get("error") or {}).get("details") or {}).get("error_code")) if isinstance(body, dict) else None
if code == "enforced_spend_limit_reached": # 上限到達の 429 は待っても通らない
raise
if e.status_code not in RETRYABLE or attempt == max_attempts:
raise
ra = e.response.headers.get("retry-after")
wait = float(ra) if ra and ra.replace(".", "", 1).isdigit() else min(0.5 * 2 ** (attempt - 1), wait_cap_s)
wait = min(wait, wait_cap_s) * (1 - 0.25 * random.random())
except (anthropic.APIConnectionError, anthropic.APITimeoutError) as e:
last = e
if attempt == max_attempts:
raise
wait = min(0.5 * 2 ** (attempt - 1), wait_cap_s)
if time.monotonic() + wait > deadline: # 予算を超える待ちは、待たずに諦める
raise last # 直近の本物のエラーをそのまま返す
time.sleep(wait)最初に書いたとき、予算を超えた分岐で raise だけを書いておりました。except ブロックの外で素の raise を実行すると、直近の例外が残っておらず RuntimeError になります。実際、予算切れの試験で本物のエラーではなく RuntimeError が返ってきて、気づきました。直近の例外を last に控えておいて raise last と書くのが、いまの形です。
この呼び出し口を、同じ模擬サーバーで走らせた結果です。
retry-after: 120 / 待ち上限 3s / 予算 20s 試行=2 結果=ok 合計=2.47s
retry-after: 120 / 待ち上限 30s / 予算 5s 試行=1 結果=RateLimitError 合計=0.00s
上限到達の 429(retry-after なし) 試行=1 結果=RateLimitError 合計=0.00s
529 を 2 回 試行=3 結果=ok 合計=1.20s
400 試行=1 結果=BadRequestError 合計=0.00s
既定SDK / 上限到達の 429: RateLimitError 試行= 3 1.25s| 状況 | 設定 | 結果 |
|---|---|---|
| retry-after: 120 | 待ち上限 3 秒、予算 20 秒 | 2 回目で成功。合計 2.5 秒(120 秒待たない) |
| retry-after: 120 | 待ち上限 30 秒、予算 5 秒 | 待たずに諦め、1 回で終了 |
| 上限到達の 429 | 既定 | 1 回で返す(既定の SDK は 3 回・1.25 秒) |
| 529 を 2 回 | 既定 | 3 回目で成功。合計 1.2 秒 |
| 400 | 既定 | 1 回で返す |
待つ長さを決めるのは、サーバーではなく呼ぶ側の予算です。 この線引きだけは、ライブラリの既定に委ねないようにしています。
SDK に任せるか、自分で握るか
すべてを自前にする必要はありません。私が目安にしているのは、次のとおりです。
| 呼び出しの性質 | おすすめ | 理由 |
|---|---|---|
| 人が画面の前で待つ対話 | SDK の既定のまま、または max_retries を 1 に下げる | 待ちが短く、長く待たされるより早く失敗を見せたい |
| 夜間のバッチ・定期ジョブ | 呼び出し口で、予算と待ち上限を持つ | 無駄な往復と、想定外の長い待ちを避けたい |
| 並列で大量に流す処理 | 呼び出し口に加え、並列度の側も絞る | 全員が同じ時刻に再試行すると、また詰まる |
| 試験・CI | max_retries=0 | 失敗を隠さず、すぐ気づきたい |
落とし穴
with_options(max_retries=0)は、そのクライアントのコピーにだけ効きます。 元のクライアントの設定は変わりません。retry-afterを自分で読むときは、数値以外も来ます。 SDK は日付形式も解釈します。私の呼び出し口は数値だけを扱い、読めなければ指数バックオフに落としています。- タイムアウトは再試行の対象です。 応答 3 秒に対して
timeout=1を指定すると、3 回試して 4.4 秒かかりました。短いタイムアウトと再試行の組み合わせは、総時間で見てください。 - この測定は Python 版だけです。 TypeScript 版などの既定値は確かめておりません。
- 模擬サーバーの結果は、実 API の保証ではありません。 実際の応答ヘッダーは、本番の呼び出しで一度ログに残してから判断してください。
並列で流すジョブの余白をどう配分するかは、上限が倍になった日に決めたこと — 共有APIキーで定期ジョブを束ねる余白予算の設計 に書きました。待ち時間そのものを縮めたい場合は、Claude API のレイテンシを下げる4つのインフラ施策 — モデルを変える前に試したい打ち手 が入口になります。
模擬サーバーの全文
再現していただけるよう、使った模擬サーバーを載せます。標準ライブラリだけで動きます。起動は python3 mock_server.py 8765、クライアント側は base_url="http://127.0.0.1:8765" と api_key="test" を指定し、extra_headers={"x-scenario": "429-always"} のようにシナリオを選びます。
# 模擬 API サーバー: リクエストごとに時刻を記録し、シナリオ (X-Scenario ヘッダ) どおりに返す
import json, sys, time, threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
LOG, COUNTS = [], {}
OK = {"id":"msg_test","type":"message","role":"assistant","model":"claude-sonnet-5-5",
"content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","stop_sequence":None,
"usage":{"input_tokens":1,"output_tokens":1}}
def err(kind, msg): return {"type":"error","error":{"type":kind,"message":msg}}
class H(BaseHTTPRequestHandler):
def log_message(self, *a): pass
def do_GET(self):
if self.path == "/log": self._send(200, LOG, {})
elif self.path == "/reset": LOG.clear(); COUNTS.clear(); self._send(200, {"ok":1}, {})
def do_POST(self):
n = int(self.headers.get("content-length", 0)); self.rfile.read(n)
sc = self.headers.get("x-scenario", "ok")
COUNTS[sc] = COUNTS.get(sc, 0) + 1; k = COUNTS[sc]
LOG.append({"scenario": sc, "n": k, "t": time.time()})
if sc == "429-always": return self._send(429, err("rate_limit_error","slow down"), {})
if sc == "429-retry-after-2": return self._send(429 if k == 1 else 200, err("rate_limit_error","x") if k == 1 else OK, {"retry-after": "2"} if k == 1 else {})
if sc == "429-retry-after-ms":return self._send(429 if k == 1 else 200, err("rate_limit_error","x") if k == 1 else OK, {"retry-after-ms": "300"} if k == 1 else {})
if sc == "429-retry-after-120":return self._send(429 if k == 1 else 200, err("rate_limit_error","x") if k == 1 else OK, {"retry-after": "120"} if k == 1 else {})
if sc == "spend-cap": return self._send(429, {"type":"error","error":{"type":"rate_limit_error","message":"cap","details":{"error_code":"enforced_spend_limit_reached"}}}, {})
if sc == "529-twice": return self._send(529 if k <= 2 else 200, err("overloaded_error","Overloaded") if k <= 2 else OK, {})
if sc == "400": return self._send(400, err("invalid_request_error","bad"), {})
if sc == "500-no-retry": return self._send(500, err("api_error","boom"), {"x-should-retry": "false"})
if sc == "400-force-retry": return self._send(400 if k == 1 else 200, err("invalid_request_error","bad") if k == 1 else OK, {"x-should-retry": "true"} if k == 1 else {})
if sc == "drop": self.connection.close(); return
if sc == "slow": time.sleep(3); return self._send(200, OK, {})
return self._send(200, OK, {})
def _send(self, code, body, hdr):
b = json.dumps(body).encode()
try:
self.send_response(code); self.send_header("content-type", "application/json")
for a, v in hdr.items(): self.send_header(a, v)
self.send_header("content-length", str(len(b))); self.end_headers(); self.wfile.write(b)
except Exception: pass
ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), H).serve_forever()結び
今日いちばん小さくできる確認は、自分のコードで max_retries を明示することです。省略したままだと既定の 2 回が黙って効きますので、まず値を書いて、一度だけ模擬サーバーで 429 を返してみていただければと思います。
待たされる長さは、相手の言い値ではなく自分の予算で決めます。この線引きだけは、これからも守ってまいります。