◉CLAUDE LABJP
●2.1.296 — Subagents can set their own autoCompactWindow, and an env var pins the model used by workflow agents●HAIKU 5.5 — Since 2.1.293 (Oct 7), claude-haiku-5-5 is the default Haiku model on the Anthropic API●SONNET 4.5 — Retires on the Claude API on 11/30, 47 days left. Migrate to Sonnet 5.5●Q&A — A write-up shows three places to look when a Mod draws nothing and reports no error●AGENTS.MD — One write-up explains how to split the entry points when Codex and Claude Code share a folder: AGENTS.md and CLAUDE.md●NEW — Approval prompts for "cd, git add, commit" every time: not a missing setting, but how it works●2.1.296 — Subagents can set their own autoCompactWindow, and an env var pins the model used by workflow agents●HAIKU 5.5 — Since 2.1.293 (Oct 7), claude-haiku-5-5 is the default Haiku model on the Anthropic API●SONNET 4.5 — Retires on the Claude API on 11/30, 47 days left. Migrate to Sonnet 5.5●Q&A — A write-up shows three places to look when a Mod draws nothing and reports no error●AGENTS.MD — One write-up explains how to split the entry points when Codex and Claude Code share a folder: AGENTS.md and CLAUDE.md●NEW — Approval prompts for "cd, git add, commit" every time: not a missing setting, but how it works
Articles/API & SDK
⬡ API & SDK/2026-10-14Intermediate

A 429 With retry-after: 120 Makes the Anthropic SDK Try to Wait 120 Seconds

I ran the Anthropic SDK's automatic retries against a mock server in 12 scenarios. Two retries by default, retry-after obeyed with no cap, and spend-limit 429s that no amount of waiting fixes. Code for a retry wrapper with a wait cap and a time budget, plus the logs.

Anthropic SDK5retries4294retry-afterPython19

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 anthropic 1.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-after follows 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.43s

The same results as a table:

SituationAttemptsWaitsWhat it shows
429 forever / default30.48 s, 0.98 sTwo retries, then gives up after about 1.5 s
429 forever / max_retries=01noneNo retries
429 forever / max_retries=560.45, 0.78, 1.98, 3.22, 7.92 sDoubles each time, tops out near 8 s. 14 s in total
429 + retry-after: 222.0 sWaits exactly as told
429 + retry-after-ms: 30020.3 sReads the millisecond header too
429 + retry-after: 1202asked to sleep 120 sNo cap on the value; it tries to wait it all
529 overloaded twice30.39, 0.92 s5xx is retried
400 invalid_request1noneNot retried
500 + x-should-retry: false1noneThe server's instruction wins
400 + x-should-retry: true20.47 sEven a 400 is retried when told to
Connection dropped30.38, 0.99 sConnection errors are retried
3 s response / timeout=131.49, 1.94 sTimeouts 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:

  1. If x-should-retry is true or false, follow it.
  2. Otherwise retry 408, 409, 429 and anything 500 or above.
  3. Wait time comes from retry-after-ms, then retry-after, used as-is when present.
  4. 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-after is 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
SituationSettingsResult
retry-after: 120wait cap 3 s, budget 20 sSucceeds on attempt 2, 2.5 s total (does not wait 120 s)
retry-after: 120wait cap 30 s, budget 5 sGives up without waiting, one attempt
Spend-limit 429defaultsReturned after one attempt (default SDK: 3 attempts, 1.25 s)
529 twicedefaultsSucceeds on attempt 3, 1.2 s total
400defaultsReturned 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 callWhat I'd doWhy
Interactive, a person waitingKeep the SDK defaults, or lower max_retries to 1Waits are short, and an early failure beats a long stall
Nightly batches and scheduled jobsUse the wrapper with a budget and a wait capAvoid wasted round trips and surprise long waits
Large parallel fan-outWrapper, plus limit concurrency itselfIf everyone retries at the same moment, it jams again
Tests and CImax_retries=0Don't hide failures

Pitfalls

  • with_options(max_retries=0) returns a copy. The original client keeps its settings.
  • retry-after isn'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=1 took 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.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⬡ API & SDK2026-04-18
Building a Real-Time AI Processing Pipeline with Claude API and Apache Kafka
Learn how to integrate Claude API into Apache Kafka event streams with production-grade patterns. Implement smart buffering, model routing, and Dead Letter Queues to run large-scale real-time AI analysis at low cost.
⬡ API & SDK2026-03-15
Building an AI Chatbot with Claude API — Streaming, Conversation History & Cost Optimization
A record of building a chatbot on the Claude API from a minimal script upward: conversation history, streaming, a FastAPI endpoint, cost trimming, LINE/Telegram/Discord, and PII masking. The retry logic was rebuilt around measured jitter and the retry-after header.
⬡ API & SDK2026-10-13
Haiku 5.5 is cheaper per token, but your bill jumps fivefold past about 77k Haiku 4.5 tokens
On the price sheet, Haiku 5.5 costs roughly a tenth of Haiku 4.5. But the same text becomes about 30% more tokens, and a prompt over 100k tokens is billed at five times the rate. Here is the boundary in Haiku 4.5 terms, a script I actually ran, and the changes that return a 400.