I had assumed the SDK's default retries were good enough for 429s, and I wrote the code that calls the API from my own service on that assumption. Then it struck me that I couldn't say how many times the default retries, or how long it waits. So I opened the SDK source.
Reading _calculate_retry_timeout, I stopped. When a retry-after header is present, the comment says it just does what the server says, whatever the size. So if the server says 120 seconds, does it really stand still for 120 seconds? Here is what I found when I tested it.
The short answer first. In the Python SDK (1.13.0) the defaults are two retries, exponential backoff starting at 0.5 seconds, and retry-after obeyed with no upper bound. As an indie developer calling the API from my own services, that missing bound is the part I'd least like to discover in production.
What I tested, and what I didn't
The measurements below are against a mock server on my own machine. I did not call Anthropic's API. What I checked is client-side behavior only: how many times, and how long, the SDK retries after a given response.
- SDK: Python
anthropic1.13.0 - Environment: Python 3.10 on Linux
- Server: a mock written with only the standard library (full source at the end), which logs request times and returns 429, 529, 400 and so on based on a scenario header
- Whether the real API sends
retry-afterfollows the official docs (see below)
Twelve scenarios
Terminal output, verbatim:
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.43sThe same results as a table:
| Situation | Attempts | Waits | What it shows |
|---|---|---|---|
| 429 forever / default | 3 | 0.48 s, 0.98 s | Two retries, then gives up after about 1.5 s |
| 429 forever / max_retries=0 | 1 | none | No retries |
| 429 forever / max_retries=5 | 6 | 0.45, 0.78, 1.98, 3.22, 7.92 s | Doubles each time, tops out near 8 s. 14 s in total |
| 429 + retry-after: 2 | 2 | 2.0 s | Waits exactly as told |
| 429 + retry-after-ms: 300 | 2 | 0.3 s | Reads the millisecond header too |
| 429 + retry-after: 120 | 2 | asked to sleep 120 s | No cap on the value; it tries to wait it all |
| 529 overloaded twice | 3 | 0.39, 0.92 s | 5xx is retried |
| 400 invalid_request | 1 | none | Not retried |
| 500 + x-should-retry: false | 1 | none | The server's instruction wins |
| 400 + x-should-retry: true | 2 | 0.47 s | Even a 400 is retried when told to |
| Connection dropped | 3 | 0.38, 0.99 s | Connection errors are retried |
| 3 s response / timeout=1 | 3 | 1.49, 1.94 s | Timeouts are retried: 4.4 s in total |
The bold row is what caught my eye. Actually waiting 120 seconds would have stalled the experiment, so I replaced time.sleep to record what the SDK asked to sleep. It asked for 120.0 seconds. To be precise: that is the requested value, not time I waited.
From the source, the decision order is:
- If
x-should-retryistrueorfalse, follow it. - Otherwise retry 408, 409, 429 and anything 500 or above.
- Wait time comes from
retry-after-ms, thenretry-after, used as-is when present. - Otherwise double from 0.5 s up to 8 s, with up to 25% subtracted as jitter.
A spend-limit 429 won't clear by waiting
One more thing from re-reading the docs. The rate limits page says a 429 caused by reaching a spend limit has no retry-after header and keeps failing, SDK retries included, until access resumes. On the Messages API you can tell it apart with error.details.error_code set to enforced_spend_limit_reached.
I had the mock return that kind of 429 to a default SDK client: three attempts, 1.25 seconds, and the same RateLimitError at the end. For one call that's nothing. For a nightly batch of thousands, it's wasted round trips piling up.
The fix: a call path with a wait cap and a time budget
I switched the SDK's retries off and took them over myself, with three rules:
- Cap each wait (a huge
retry-afteris honored only up to the cap) - Keep a total time budget (a wait that would exceed it is skipped; give up instead)
- Return spend-limit 429s immediately, without retrying
"""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)My first version had a bare raise in the over-budget branch. Outside an except block there is no current exception, so Python raises RuntimeError instead. I only noticed because my budget test returned a RuntimeError rather than the real error. Keeping the latest exception in last and raising that is the current form.
The wrapper against the same mock server:
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| Situation | Settings | Result |
|---|---|---|
| retry-after: 120 | wait cap 3 s, budget 20 s | Succeeds on attempt 2, 2.5 s total (does not wait 120 s) |
| retry-after: 120 | wait cap 30 s, budget 5 s | Gives up without waiting, one attempt |
| Spend-limit 429 | defaults | Returned after one attempt (default SDK: 3 attempts, 1.25 s) |
| 529 twice | defaults | Succeeds on attempt 3, 1.2 s total |
| 400 | defaults | Returned after one attempt |
How long to wait is my budget's decision, not the server's. That's the one line I don't hand over to a library default.
Leave it to the SDK, or take it over
You don't need to own everything. My rule of thumb:
| Kind of call | What I'd do | Why |
|---|---|---|
| Interactive, a person waiting | Keep the SDK defaults, or lower max_retries to 1 | Waits are short, and an early failure beats a long stall |
| Nightly batches and scheduled jobs | Use the wrapper with a budget and a wait cap | Avoid wasted round trips and surprise long waits |
| Large parallel fan-out | Wrapper, plus limit concurrency itself | If everyone retries at the same moment, it jams again |
| Tests and CI | max_retries=0 | Don't hide failures |
Pitfalls
with_options(max_retries=0)returns a copy. The original client keeps its settings.retry-afterisn't always a number. The SDK also parses date formats. My wrapper handles numbers only and falls back to exponential backoff if it can't read the value.- Timeouts are retried. A 3-second response with
timeout=1took three attempts and 4.4 seconds. Look at total time when you combine short timeouts with retries. - This is Python only. I haven't checked the TypeScript SDK's defaults.
- A mock isn't a guarantee about the real API. Log the real response headers once in production before you rely on them.
How to divide headroom among jobs that run in parallel is covered in What I Decided the Day the Ceiling Doubled: A Headroom Budget for Scheduled Jobs on One Shared API Key. If you want to shrink the waiting itself, Four Infrastructure Levers That Cut Claude API Latency Before You Touch the Model is a good place to start.
The mock server, in full
So you can reproduce this: standard library only. Start it with python3 mock_server.py 8765, point the client at base_url="http://127.0.0.1:8765" with api_key="test", and pick a scenario with 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()Closing
The smallest check you can make today is to set max_retries explicitly in your own code. If you leave it out, the default of two applies without telling you. Write the value down, then return a 429 from the mock once and watch what happens.
I'd rather set how long I'm willing to wait than accept whatever number the other side offers.