はじめに / 対象と前提
Claude API を Python から呼んでいて、こういう経験はないだろうか。
-
max_tokensを増やしただけで、API を叩く前にValueErrorで落ちる -
timeout=30を渡したのに、30 秒経っても処理が返ってこない - タイムアウトさせたつもりが、ログを見ると同じリクエストが 3 回飛んでいる
自分は AI エージェントを無人で長時間回す仕組みを作っていて、「1 回の API 呼び出しが何秒で諦めるか」を決めないと後段のスケジュールが全部ズレる、という問題にぶつかった。調べてみると、Anthropic Python SDK のタイムアウトは 3 つの仕様が重なって直感と違う動き をする。
この記事は、その 3 つをローカルのモックサーバーで実際に再現して、どう書けば「指定した時間で確実に諦める」呼び出しになるかをまとめたもの。
想定読者
- Claude API を Python SDK(
anthropicパッケージ)で叩いている人 - バッチ処理やエージェントのループなど、人が見ていない場所で API を呼んでいる人
検証環境
- Python 3.14
-
anthropic1.15.0(内部の HTTP クライアントはhttpx22.13.1) - macOS(Apple Silicon)
SDK のバージョンで細部が変わる部分なので、自分の環境では python -c "import anthropic; print(anthropic.__version__)" で確認してほしい。
TL;DR
-
max_tokensが大きい(目安 21,334 以上)非ストリーミング呼び出しは、SDK がリクエスト前にValueErrorを投げる。messages.stream()+get_final_message()に切り替える -
timeout=2.0は「全体で 2 秒」ではなく「データが 2 秒途切れたら諦める」。少しずつ返ってくる応答は 51 秒かかっても止まらない。全体の締め切りはasyncio.wait_forで外側に掛ける - タイムアウトも 自動リトライ対象(デフォルト 2 回)。
timeout=2.0でも実際は 7.6 秒待たされ、リクエストは 3 回飛ぶ。max_retriesを明示する
手順 / 動かし方
SDK のデフォルト値を確認する
まず、何も指定しないときの値。SDK の定数を見るのが一番早い。
from anthropic import _constants as c
print(c.DEFAULT_TIMEOUT) # Timeout(connect=5.0, read=600, write=600, pool=600)
print(c.DEFAULT_MAX_RETRIES) # 2
print(c.INITIAL_RETRY_DELAY) # 0.5
print(c.MAX_RETRY_DELAY) # 8.0
- タイムアウトは 10 分(接続だけ 5 秒)
- リトライは 2 回(= 最大 3 回リクエストが飛ぶ)
- リトライ間隔は 0.5 秒から始まって最大 8 秒
_constants はアンダースコア付きの内部モジュールなので、コードから参照するのではなく「確認用」に留めておく。
検証用のモックサーバー
本物の API で「応答が遅い」状況を作るのは難しいので、ローカルに偽の Messages API を立てた。base_url を差し替えれば SDK はそちらに向く。
import json, threading, time, http.server, socketserver
BODY = json.dumps({
"id": "msg_x", "type": "message", "role": "assistant", "model": "m",
"content": [{"type": "text", "text": "ok"}],
"stop_reason": "end_turn", "stop_sequence": None,
"usage": {"input_tokens": 1, "output_tokens": 1},
}).encode()
class Handler(http.server.BaseHTTPRequestHandler):
hits = 0
def log_message(self, *a): pass
def do_POST(self):
Handler.hits += 1
self.rfile.read(int(self.headers["Content-Length"]))
if self.server.mode == "slow":
time.sleep(5) # ヘッダすら返さず沈黙
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
if self.server.mode == "drip": # 1 バイトずつ 0.12 秒間隔で返す
for b in BODY:
self.wfile.write(bytes([b])); self.wfile.flush(); time.sleep(0.12)
else:
self.wfile.write(BODY)
def serve(mode):
s = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Handler)
s.mode, s.daemon_threads = mode, True
threading.Thread(target=s.serve_forever, daemon=True).start()
return f"http://127.0.0.1:{s.server_address[1]}"
slow は「サーバーが固まった」状態、drip は「生成が遅くてちょっとずつ返ってくる」状態の再現。
ハマりどころ
ハマり 1:max_tokens を上げただけで ValueError
長い出力が欲しくて max_tokens を 32000 にしたら、API を叩く前に落ちた。
import anthropic
client = anthropic.Anthropic(api_key="dummy")
client.messages.create(
model="claude-sonnet-4-5",
max_tokens=32000,
messages=[{"role": "user", "content": "hi"}],
)
ValueError: Streaming is required for operations that may take longer than 10 minutes. See https://platform.claude.com/docs/en/cli-sdks-libraries/sdks/python#long-requests for more details
API キーがダミーでもこのエラーになる=通信前に SDK 側で弾いている。中身を読むとこういう計算をしていた。
# SDK 内部(要約)
expected_time = 60 * 60 * max_tokens / 128_000 # 128k トークンで 1 時間と見積もる
if expected_time > 60 * 10: # 10 分を超えそうなら
raise ValueError("Streaming is required ...")
つまり max_tokens > 21,333 あたりで非ストリーミングは拒否される。一部の古いモデルには別途さらに小さい上限が設定されている。
回避策:ストリーミングで受けて、最後にまとめて取り出す。コードの形はほとんど変わらない。
with client.messages.stream(
model="claude-sonnet-4-5",
max_tokens=32000,
messages=[{"role": "user", "content": "長文を書いて"}],
) as stream:
message = stream.get_final_message() # 戻り値は create() と同じ Message 型
print(message.content[0].text)
ちなみにクライアントかリクエストに timeout を 明示的に渡すと、このチェック自体がスキップされる(自分の環境ではダミー URL への接続エラー APIConnectionError まで進んだ)。ただ、長時間 HTTP 接続を握りっぱなしにすると途中のプロキシやロードバランサに切られやすいので、素直にストリーミングにする方が安全。
ハマり 2:timeout=2 なのに 51 秒返ってこない
drip サーバーに timeout=2.0、リトライなしで投げた。
import time
url = serve("drip")
client = anthropic.Anthropic(api_key="dummy", base_url=url, timeout=2.0, max_retries=0)
t = time.monotonic()
r = client.messages.create(model="m", max_tokens=100,
messages=[{"role": "user", "content": "hi"}])
print(f"ok after {time.monotonic() - t:.1f}s", r.content[0].text)
ok after 51.6s ok
2 秒で止まるどころか、51.6 秒かけて正常終了 した。
原因は、SDK に渡した数値が HTTP クライアントの Timeout に変換され、connect / read / write / pool それぞれの待ち時間 として使われること。read=2.0 は「次のデータが 2 秒来なければ諦める」という意味で、0.12 秒ごとに 1 バイトでも届いていればずっと待ち続ける。
ストリーミングでも同じで、イベントが途切れなく流れてくる限り、全体が何分かかっても止まらない。
回避策:SDK の timeout は「無応答の検知」用と割り切り、全体の締め切りは外側で掛ける。非同期クライアントなら asyncio.wait_for が一番シンプル。
import asyncio
import anthropic
import httpx2 # anthropic 1.15.0 の依存。古い版は httpx
async def call_with_deadline(client, deadline_sec: float, **kwargs):
return await asyncio.wait_for(client.messages.create(**kwargs), timeout=deadline_sec)
async def main():
client = anthropic.AsyncAnthropic(
api_key="dummy", base_url=serve("drip"),
timeout=httpx2.Timeout(60.0, connect=5.0), # 無応答 60 秒で諦める
max_retries=0,
)
t = time.monotonic()
try:
await call_with_deadline(client, 5, model="m", max_tokens=100,
messages=[{"role": "user", "content": "hi"}])
except TimeoutError:
print(f"TimeoutError after {time.monotonic() - t:.1f}s")
asyncio.run(main())
TimeoutError after 5.0s
今度はきっちり 5.0 秒で打ち切れた。wait_for はタイムアウト時に内側のタスクをキャンセルするので、HTTP 接続もちゃんと閉じられる。
同期コードしかない場合は、別スレッドで呼んで future.result(timeout=...) で待つ手もある。ただしこの方法だと 待つのをやめるだけでスレッド内の通信は続く ので、プロセスが長生きするならリソースが溜まっていく。無人で回すなら非同期クライアントに寄せる方が後で困らない。
ハマり 3:タイムアウトしたリクエストが 3 回飛んでいる
今度は slow サーバー(5 秒沈黙)に、timeout=2.0 だけ指定して投げた。max_retries は指定しない。
url = serve("slow")
Handler.hits = 0
client = anthropic.Anthropic(api_key="dummy", base_url=url, timeout=2.0)
t = time.monotonic()
try:
client.messages.create(model="m", max_tokens=100,
messages=[{"role": "user", "content": "hi"}])
except anthropic.APITimeoutError:
print(f"APITimeoutError after {time.monotonic() - t:.1f}s hits={Handler.hits}")
APITimeoutError after 7.6s hits=3
2 秒で諦めるつもりが 7.6 秒。サーバー側には 3 回 リクエストが届いている。
SDK は 429 や 5xx だけでなく、接続エラーとタイムアウトも自動でリトライする。デフォルトの max_retries=2 なので、最悪の待ち時間はざっくりこうなる。
timeout × (max_retries + 1) + バックオフ待ち
= 2 × 3 + 約1.6 秒
≒ 7.6 秒
デフォルトの 10 分タイムアウトのままだと、最悪 30 分以上 1 回の呼び出しで固まる計算になる。
さらに怖いのは、本物の API では「クライアントが待ちきれずに切った」だけでサーバー側の生成は進んでいた、というケースがあり得ること。リトライのたびに同じプロンプトが送られるので、トークン課金が二重三重になる可能性 がある。
回避策:用途ごとに max_retries を明示する。クライアント全体の設定はそのままに、特定の呼び出しだけ変えるなら with_options が便利。
client = anthropic.Anthropic(timeout=httpx2.Timeout(60.0, connect=5.0), max_retries=2)
# 重い生成は自前で制御したいのでリトライなし
heavy = client.with_options(max_retries=0)
# 軽い分類は短く・リトライあり
light = client.with_options(timeout=15.0, max_retries=2)
「SDK のリトライ」と「自前のリトライループ」を両方入れると回数が掛け算になるので、どちらか片方に寄せる のがおすすめ。
背景・補足:3 つをまとめた最終形
自分が無人のエージェントで使っている形はこう。役割を分けて考えるとスッキリする。
| 設定 | 役割 | 目安 |
|---|---|---|
Timeout(read=..., connect=5) |
無応答(固まり)の検知 | 60〜120 秒 |
max_retries |
一時的な失敗の吸収 | 0〜2 回 |
asyncio.wait_for |
1 呼び出しの総時間の上限 | 業務要件で決める |
messages.stream() |
長い出力の受け取り | max_tokens が大きいとき必須 |
async def ask(client, prompt: str, *, deadline: float = 300, max_tokens: int = 32000):
async def _run():
async with client.messages.stream(
model="claude-sonnet-4-5",
max_tokens=max_tokens,
messages=[{"role": "user", "content": prompt}],
) as stream:
return await stream.get_final_message()
return await asyncio.wait_for(_run(), timeout=deadline)
wait_for で打ち切った場合は TimeoutError、SDK 側の無応答検知なら anthropic.APITimeoutError と、例外の型が違う 点に注意。ログで「どちらで止まったか」を区別しておくと、締め切りが短すぎるのか、API が固まっているのかを後から判断できる。
まとめ
-
max_tokensが大きい呼び出しは SDK が事前にValueErrorで止める。messages.stream()+get_final_message()に置き換える - SDK の
timeoutは「データが途切れた時間」の上限で、総時間の上限ではない。総時間はasyncio.wait_forで外から掛ける - タイムアウトも自動リトライされ、待ち時間もリクエスト数も 3 倍になる。
max_retriesは用途ごとに明示する - 挙動は SDK のバージョンで変わるので、モックサーバーで一度実測しておくと安心