◉CLAUDE LABEN
●2.1.296 — サブエージェントごとに autoCompactWindow を指定でき、ワークフロー用のモデルも環境変数で固定できます●HAIKU 5.5 — 2.1.293(10/07)から claude-haiku-5-5 が Anthropic API の既定の Haiku モデルになりました●SONNET 4.5 — Claude API での提供終了は 11/30、残り47日。移行先は Sonnet 5.5●Q&A — Mod が「エラーなしで何も描かれない」ときに見る3か所が共有されています●AGENTS.MD — Codex と Claude Code を同じフォルダで使うとき、AGENTS.md と CLAUDE.md の入口をどう分けるかの整理が出ています●NEW — 「cd して git add して commit」の承認が毎回出るのは、設定の抜けではなく仕様でした●2.1.296 — サブエージェントごとに autoCompactWindow を指定でき、ワークフロー用のモデルも環境変数で固定できます●HAIKU 5.5 — 2.1.293(10/07)から claude-haiku-5-5 が Anthropic API の既定の Haiku モデルになりました●SONNET 4.5 — Claude API での提供終了は 11/30、残り47日。移行先は Sonnet 5.5●Q&A — Mod が「エラーなしで何も描かれない」ときに見る3か所が共有されています●AGENTS.MD — Codex と Claude Code を同じフォルダで使うとき、AGENTS.md と CLAUDE.md の入口をどう分けるかの整理が出ています●NEW — 「cd して git add して commit」の承認が毎回出るのは、設定の抜けではなく仕様でした
記事一覧/API & SDK
⬡ API & SDK/2026-10-14中級

retry-after が 120 秒の 429 を受けると、Anthropic SDK の自動リトライは 120 秒待とうとしました

Anthropic SDK の自動リトライを模擬サーバーで12通り確かめました。既定は2回まで、retry-after は上限なしで従い、上限到達の429は待っても通りません。待ち時間と総時間に上限を持つ呼び出し口のコードと実測ログを載せます。

Anthropic SDK5リトライ54294retry-afterPython19

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 版 anthropic 1.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 が続く / 既定30.48 秒、0.98 秒再試行は 2 回まで。合計 1.5 秒で諦める
429 が続く / max_retries=01なし再試行しない
429 が続く / max_retries=560.45、0.78、1.98、3.22、7.92 秒待ちは倍々で、上限は 8 秒前後。合計 14 秒
429 + retry-after: 222.0 秒ヘッダーの値どおりに待つ
429 + retry-after-ms: 30020.3 秒ミリ秒版のヘッダーも読む
429 + retry-after: 1202120 秒を要求値に上限をかけず、そのまま待とうとする
529 overloaded を 2 回30.39、0.92 秒5xx は再試行の対象
400 invalid_request1なし再試行しない
500 + x-should-retry: false1なしサーバーの指示が優先される
400 + x-should-retry: true20.47 秒400 でも、指示があれば再試行する
接続切断30.38、0.99 秒接続エラーも再試行の対象
応答 3 秒 / timeout=131.49、1.94 秒タイムアウトも再試行。合計 4.4 秒かかる

太字の行が、冒頭で気づいた点です。120 秒の行だけは、実際に 120 秒待つと検証が進まないため、time.sleep を差し替えて「SDK が要求した待ち時間」を記録しました。要求値は 120.0 秒でした。待った時間ではなく要求値だと、お断りしておきます。

ソースで裏を取ると、判断はこの順です。

  1. x-should-retry ヘッダーが true か false なら、それに従います。
  2. なければ、408・409・429・500 以上のステータスを再試行します。
  3. 待ち時間は retry-after-ms、retry-after の順に読み、あればその値をそのまま使います。
  4. なければ、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 に下げる待ちが短く、長く待たされるより早く失敗を見せたい
夜間のバッチ・定期ジョブ呼び出し口で、予算と待ち上限を持つ無駄な往復と、想定外の長い待ちを避けたい
並列で大量に流す処理呼び出し口に加え、並列度の側も絞る全員が同じ時刻に再試行すると、また詰まる
試験・CImax_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 を返してみていただければと思います。

待たされる長さは、相手の言い値ではなく自分の予算で決めます。この線引きだけは、これからも守ってまいります。

シェア

お読みいただきありがとうございます

Claude Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

⬡ API & SDK2026-04-26
Claude の「Spanner Temporarily Unavailable」エラーが出たときの読み解き方と対処法
Claude API や Claude.ai で稀に発生する「Spanner temporarily unavailable」エラー。表示は短いのに原因が見えにくいこのエラーを、Anthropic のインフラ構造から逆算して読み解き、開発者が今すぐ取れる現実的な対処法を整理します。
⬡ API & SDK2026-04-18
Claude API × Apache Kafka でリアルタイムAI処理パイプラインを構築する:イベントストリームへのインテリジェント分析の組み込み
Apache Kafka のイベントストリームに Claude API を組み込む本番設計パターンを解説。スマートバッファリング・モデルルーティング・Dead Letter Queue の実装で、大規模リアルタイムAI処理を低コストで実現します。
⬡ API & SDK2026-03-15
Claude APIでAIチャットボットを実装する — ストリーミング・会話履歴・コスト最適化まで
Claude API でチャットボットを最小構成から組み上げた記録です。会話履歴の管理、ストリーミング、FastAPI での公開、履歴のコスト最適化、LINE・Telegram・Discord への接続、PII マスキングまで動くコードで扱います。リトライは待ち方を手元で計測し、ジッタと retry-after を入れた形に作り直しました。