AI

anthropic 1.6.0 は Retry-After 120 で 240 秒止まる

anthropic-sdk-python を 1.6.0 に上げると、429 の Retry-After が 60 秒を超えても従うようになる。mock server に 9 通りのヘッダを返させて 1.5.0 と待ち時間を実測し、timeout では縮まず max_retries だけが効くことを数字で示す。

SoSoraEndo2026年9月17日 09:2023 min2,161 字

動画で読む

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-After1.5.0 の wall1.6.0 の wall1.6.0 で server が受けた間隔
510.29 s10.29 s5.15 / 5.14
611.67 s122.29 s61.14 / 61.14
1201.77 s240.28 s61 / date と並走させたので server 側の間隔は取れず、wall から逆算
HTTP-date(90 秒後)1.53 s179.21 s89.19 / 90.02
nan1.66 s1.66 s0.53 / 1.12
空文字1.58 s1.61 s0.62 / 0.99
-301.58 s1.55 s0.56 / 0.98
ヘッダ無し1.57 s1.59 s0.57 / 1.01
inf1.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 no retry-after header. 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

参考文献

  1. anthropic-sdk-python v1.6.0 release notes (2026-09-15)
  2. commit 2d03ba2: fix(client): honor Retry-After values above 60 seconds
  3. anthropic-sdk-python v1.6.0 tests/test_client.py(Retry-After の parametrize 表)
  4. Claude Platform docs: Rate limits(retry-after ヘッダと spend cap の 429)
  5. openai-python v3.0.0 の HTTPX2 移行(AetherEchoes)
  6. Sidekiq 8 の retry 戦略を「諦め時」から設計する(AetherEchoes)

Reaction

Share

X (Twitter)