動画で読む
1.6.0 に上げると、429 のあとリクエストは何秒止まるのか
問いはこれだけ。anthropic-sdk-python を 1.5.0 から 1.6.0 に上げると、429 を受けたときに SDK が眠る時間が変わる。v1.6.0 の release note には Bug Fixes の 1 行に "honor Retry-After values above 60 seconds" とあるだけで、何秒が何秒になるのかは書いていない。
手元には API キーが無い。だから本物の 429 は起こせない。代わりに 429 を返すだけの mock server を localhost に立てて、venv を 2 つ作り、同じリクエストを両方のバージョンから投げて RateLimitError が上がるまでの時間を測った。数字はこの記事の中に全部貼る。
差分はコード上 1 箇所。0 < retry_after <= 60 が retry_after > 0 になった
release note では 3 つの commit が Retry-After に触れている。2d03ba2(60 秒超を守る)、909d92f(不正な値を無視する)、3d15f04(範囲外なら既定バックオフ)。GitHub API で 3 つの files を見ると、src/anthropic/_base_client.py に patch があるのは 2d03ba2 だけで、残り 2 つは types/beta/ 配下の型定義と api.md しか触っていない。
pip で入れた両バージョンの _base_client.py を diff した結果がこれ。
$ diff <(sed -n 777,836p v150/_base_client.py) <(sed -n 777,838p v160/_base_client.py)
43c43
< # If the API asks us to wait a certain amount of time (and it's a reasonable amount), just do what it says.
---
> # If the API asks us to wait a certain amount of time, just do what it says.
45,46c45,48
< if retry_after is not None and 0 < retry_after <= 60:
< return retry_after
---
> if retry_after is not None and retry_after > 0:
> # `time.sleep` raises above a platform limit, as low as 2**32 milliseconds on Windows,
> # so wait at most that long.
> return min(retry_after, 4_294_967.0)
_calculate_retry_timeout の中の 1 条件。1.5.0 は 60 秒以内のときだけヘッダに従い、それ以外は既定の指数バックオフに落ちていた。既定値は _constants.py にあって INITIAL_RETRY_DELAY = 0.5、MAX_RETRY_DELAY = 8.0、DEFAULT_MAX_RETRIES = 2。jitter は 1 - 0.25 * random() なので初回は 0.375〜0.5 秒、2 回目は 0.75〜1.0 秒になる。
1.6.0 は正の値なら全部従う。上限の 4,294,967 秒は 49.7 日で、コメントにある通り Windows の time.sleep が 2^32 ミリ秒で例外を投げるのを避けるための数字だ。値を解釈する側の _parse_retry_after_header は両バージョンで同じ。retry-after-ms を先に見て、次に retry-after を float として読み、それも駄目なら HTTP-date として email.utils.parsedate_tz に通す。
つまり HTTP-date は 1.5.0 でも読めていた。上流の tests/test_client.py にも 1.5.0 の時点で "Fri, 29 Sep 2023 16:26:57 GMT" を 20 秒として期待する行がある。着手前は「1.5.0 は日付形式を無視する」と思っていたが、無視していたのは形式ではなく 60 秒の上限の方だった。
nan と空文字は、1.5.0 でも既定バックオフに落ちていた
release note の "ignore invalid Retry-After values" を読んで、1.6.0 で新しく守られるようになったのかと思ったが、両バージョンで float("nan") > 0 は False、0 < nan <= 60 も False。空文字は float("") で ValueError、日付としても parse できず None。どれも前から既定バックオフに落ちる。
上流のテストで 1.6.0 に変わった parametrize の行は 5 つ。期待値が変わったのが 3 行、新規が 2 行だ。
[3, "61", 61], # 1.5.0 では 0.5
[3, "Fri, 29 Sep 2023 16:27:38 GMT", 61], # 1.5.0 では 0.5
[3, "99999999999999999999999999999999999", 4294967], # 1.5.0 では 0.5
[3, "inf", 4294967], # 新規
[3, "nan", 0.5], # 新規
nan の行は「変わっていないことを固定するテスト」で、挙動が変わったのは 61 以上の行だけ。release note の 3 行は、コード上は 1 つの条件式の変更に集約されている。
mock に 9 通りの Retry-After を返させると、分かれたのは 61 秒以上だけだった
http.server で 429 を返す server を書き、path の /case/<name>/v1/messages で Retry-After の値を切り替えるようにした。SDK 側は Anthropic(base_url="http://127.0.0.1:18160/case/61", api_key="dummy", max_retries=2) で messages.create を 1 回呼ぶだけ。max_retries=2 なので、リクエストは初回 + 再試行 2 回の 3 回飛ぶ。
| Retry-After | 1.5.0 の wall | 1.6.0 の wall | 1.6.0 で server が受けた間隔 |
|---|---|---|---|
5 | 10.29 s | 10.29 s | 5.15 / 5.14 |
61 | 1.67 s | 122.29 s | 61.14 / 61.14 |
120 | 1.77 s | 240.28 s | 61 / date と並走させたので server 側の間隔は取れず、wall から逆算 |
| HTTP-date(90 秒後) | 1.53 s | 179.21 s | 89.19 / 90.02 |
nan | 1.66 s | 1.66 s | 0.53 / 1.12 |
| 空文字 | 1.58 s | 1.61 s | 0.62 / 0.99 |
-30 | 1.58 s | 1.55 s | 0.56 / 0.98 |
| ヘッダ無し | 1.57 s | 1.59 s | 0.57 / 1.01 |
inf | 1.60 s | 実走していない |
wall は time.monotonic() で測った RateLimitError までの時間。右端の列は server 側で time.monotonic() を記録したログの差分で、SDK が x-stainless-retry-count ヘッダに再試行回数を載せてくるので、それも一緒に残した。
$ grep "case=61" log/server-v160.log
679604.559 case=61 retry_count=0 retry-after='61'
679665.703 case=61 retry_count=1 retry-after='61'
679726.844 case=61 retry_count=2 retry-after='61'
61.144 秒、61.141 秒。ヘッダの値そのままで、jitter は掛かっていない。既定バックオフに落ちる行の 0.5〜0.6 / 1.0〜1.1 秒は、0.375〜0.5 と 0.75〜1.0 に HTTP 往復の数十ミリ秒が乗った値になっている。
AsyncAnthropic でも試した。Retry-After 5 で 10.03 秒。同じ _calculate_retry_timeout を通るので当然そうなるが、61 以上は async では走らせていない。
inf は mock に投げなかった。直接呼ぶと 4,294,967 秒と返ってきたから
inf を 1.6.0 の mock に投げると 49.7 日眠るはずなので、実走の代わりに _calculate_retry_timeout を直接呼んで sleep 秒数だけ出した。
$ ~/…/v160/bin/python direct.py
anthropic 1.6.0
5 -> sleep 5.000 s
60 -> sleep 60.000 s
61 -> sleep 61.000 s
120 -> sleep 120.000 s
3600 -> sleep 3600.000 s
4294967 -> sleep 4294967.000 s
4294968 -> sleep 4294967.000 s
1e9 -> sleep 4294967.000 s
inf -> sleep 4294967.000 s
nan -> sleep 0.470 s
-30 -> sleep 0.411 s
empty -> sleep 0.393 s
date+90s -> sleep 89.009 s
date-60s -> sleep 0.378 s
ms=250 (retry-after-ms) -> sleep 0.250 s
最後の行は retry-after-ms: 250 で、秒ではなくミリ秒のヘッダも読める。1.5.0 で同じ表を出すと、61 以上と date+90s の行が全部 0.37〜0.47 秒になり、それ以外の行は 1.6.0 と同じ値になる。inf が 1.6.0 で「正の値」として通るのは、上流のテストがそれを期待値にしている以上、仕様だと読むしかない。上流が inf を返すことは無いと思うが、間に置いたプロキシやゲートウェイが変な値を書く可能性は自分の環境次第だ。
timeout を 10 秒にしても 240 秒は縮まない。縮むのは max_retries だけ
Retry-After を 120 に固定して、クライアントの設定だけ変えて 1.6.0 で測った。
$ for v in mr0 self mr1; do python client_opts.py 18160 $v; done; python client_opts.py 18160 mr2_t10
version=1.6.0 variant=mr0 wall=0.00s status=429
attempt=0 retry-after=120.0 -> give up
version=1.6.0 variant=self wall=0.00s status=429
version=1.6.0 variant=mr1 wall=120.14s status=429
version=1.6.0 variant=mr2_t10 wall=240.25s status=429
timeout=httpx.Timeout(10.0) を渡した mr2_t10 が 240.25 秒。timeout は 1 リクエストの接続と読み取りに掛かるもので、再試行の間の time.sleep には掛からない。10 秒で返ってくると思って timeout を短くしても、429 のあとの待ちには何の効果も無い。
mr1 は max_retries=1 で 120.14 秒。mr0 は 0.00 秒。待ち時間は Retry-After × max_retries で、それ以外の変数は無い。
self は max_retries=0 にして RateLimitError を自分で捕まえ、e.response.headers["retry-after"] を読んで、自分の上限(ここでは 10 秒)を超えていたら待たずに諦める形。この記事の計測スクリプトなので短いが、書いたのはこれだけ。
c = anthropic.Anthropic(base_url=..., api_key="dummy", max_retries=0)
CAP = 10.0
for attempt in range(3):
try:
c.messages.create(model="claude-sonnet-5", max_tokens=8, messages=[{"role": "user", "content": "hi"}]); break
except anthropic.RateLimitError as e:
ra = float(e.response.headers.get("retry-after", "0"))
if ra > CAP: raise
time.sleep(ra)
ちなみに最初に書いた計測スクリプトは import httpx で落ちた。1.6.0 の依存は httpx2<3,>=2.0.0 で、_base_client.py も import httpx2 になっている。httpx.Timeout を渡すつもりなら import httpx2 as httpx にする。この移行は openai-python v3.0.0 の HTTPX2 移行 と同じ流れで、Stainless 生成の SDK が揃って乗り換えている。
本物の API が 61 秒より長い Retry-After を返すかは、確かめていない
API キーが無いので、ここは docs しか根拠が無い。Rate limits のページ の Response headers 表にはこうある。
retry-after: The number of seconds to wait until you can retry the request. Earlier retries will fail. Not sent with the spend-cap 429
"Earlier retries will fail" が全部だと思う。1.5.0 は 120 秒待てと言われて 0.5 秒後と 1.0 秒後に再試行していた。docs を信じるなら、その 2 回は必ず失敗する再試行で、失敗までの 1.7 秒は速いのではなく無駄だった。1.6.0 は「言われた通り待つ」だけで、待たされる時間が増えたように見えるのは、前が待っていなかったからだ。
ただし同じページに、月額の spend cap に達したときの 429 には retry-after が付かないと書いてある。
The error type is
rate_limit_error, the same as for a rate limit, but the response has noretry-afterheader. Retrying, including the SDKs' automatic retries, fails until access resumes.
この場合は 1.6.0 でも上の表の「ヘッダ無し」の行と同じで、0.5 秒と 1.0 秒待って 3 回失敗する。error.details.error_code が enforced_spend_limit_reached になるので、翌月 1 日まで何をしても通らない 429 と、数十秒待てば通る 429 は、ヘッダの有無とこのコードで見分けるしかない。SDK の再試行はどちらも区別しない。
実際の値の分布は分からない。RPM 超過なら 60 秒以内に収まりそうだし、token bucket の補充待ちなら 60 秒を超えることもありそうだが、それは想像で、数字は持っていない。
答え: 止まるのは Retry-After × max_retries 秒。1.5.0 の 1.7 秒は「必ず失敗する 2 回」の時間だった
問いに戻る。1.6.0 に上げると、429 のあとリクエストは server が言った秒数 × max_retries だけ止まる。既定の max_retries は 2 なので、Retry-After 120 なら 240 秒。1.5.0 は 60 秒を超える指示を捨てて 1.7 秒で諦めていて、その 2 回はどのみち通らなかった。
私の環境には Claude API を Python から常駐で叩くプロセスが無いので、本番でこの設定を変えた数字は無い。あるのは上の表だけだが、この表から決められることはある。同期のワーカーで 1 リクエストが数分止まると困るなら、max_retries=0 にして 429 を自分で捕まえ、retry-after を読んで待つか諦めるかを自分の締切で決める。Sidekiq の retry を「諦め時」から設計した話を 以前書いた が、締切を持つのはクライアントであってライブラリではない、という点は同じだ。
逆に、待ってでも通したいバッチなら 1.6.0 の既定のままでいい。timeout は触っても効かない。触るのは max_retries だけ。
上げた翌日に「リクエストが 4 分止まった」と言われたら、ログに x-stainless-retry-count と retry-after の値が残っているかを先に見る。それが 120 で 2 回なら、SDK は壊れていない。
Tags
参考文献
- anthropic-sdk-python v1.6.0 release notes (2026-09-15)
- commit 2d03ba2: fix(client): honor Retry-After values above 60 seconds
- anthropic-sdk-python v1.6.0 tests/test_client.py(Retry-After の parametrize 表)
- Claude Platform docs: Rate limits(retry-after ヘッダと spend cap の 429)
- openai-python v3.0.0 の HTTPX2 移行(AetherEchoes)
- Sidekiq 8 の retry 戦略を「諦め時」から設計する(AetherEchoes)