0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Anthropic Python SDK のタイムアウトを正しく効かせる実装手順 — max_tokens を上げると非ストリーミングが ValueError・timeout=2 でも51秒返らない・リトライ込みで待ち時間が3倍、3つのハマりどころ【2026】

0
Posted at

はじめに / 対象と前提

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
  • anthropic 1.15.0(内部の HTTP クライアントは httpx2 2.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 のバージョンで変わるので、モックサーバーで一度実測しておくと安心
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?