夜間バッチのコストが、ある週だけ静かに膨らんでいました。生成した記事数も、要約した件数も先週とほぼ同じなのに、api-sdk の請求だけが伸びている。原因を掴むのに半日かかりました。犯人は、あるはずのないところにいたのです。
max_tokens を 16,000 まで上げた要約タスクを、私は素直に非ストリーミングで書いていました。client.messages.create(...) を呼んで、返ってきたテキストを受け取るだけの、ごく普通のコードです。ところがこの生成、混雑した時間帯だと稀に9〜11分かかることがありました。そして Anthropic SDK の既定のリクエストタイムアウトは、ちょうど10分です。
10分を1秒でも超えると、SDK 側の読み取りタイムアウトが発火します。すると SDK は「一時的な失敗」と判断し、既定の自動リトライ(最大2回)で同じ要求をもう一度投げます。ここに落とし穴がありました。最初の要求はサーバー側でまだ生成を続けており、完了すれば出力トークン分は課金される。 そこへリトライが独立した2本目の要求として走る。私が受け取る応答は1つでも、課金は2回分。これが「静かに膨らんだ請求」の正体でした。
以下では、その仕組みを SDK の挙動レベルまで分解し、二重課金を根本から止めるための3つの設計変更を、実際に夜間バッチへ入れたコードとともにたどっていきます。個人開発で複数サイトの処理を無人で回している方ほど、気づかないまま払い続けている類の出費だと思います。
既定値を読み違えると、無人運用で牙をむく
まず、SDK が黙って持っている既定値を正確に押さえます。Anthropic の Python SDK では、クライアント生成時に次の2つが既定で効いています。
設定 既定値 無人運用での意味
timeout 10分(600秒) 長い生成が10分を超えると読み取りタイムアウトが発火する
max_retries 2回 タイムアウトを含む一時エラーで、同じ要求を最大2回まで自動再送する
自動リトライ自体は正しい機能です。429(レート制限)や 5xx、接続断のような「もう一度投げれば通る」エラーには、指数バックオフ付きの再送がよく効きます。問題は、読み取りタイムアウトが「もう一度投げれば通る」エラーではない ことです。
タイムアウトは、あくまでクライアント側が待つのをやめた合図にすぎません。サーバーはこちらの都合を知らず、粛々と生成を続けています。Messages API の課金は、生成された出力トークンに対して発生します。つまり最初の要求は、こちらが受け取らなくても完走すれば課金される。そこへリトライが2本目を走らせれば、同じ内容の生成に二重の料金を払うことになります。
Anthropic SDK は、この危険を認識しています。max_tokens を大きくした非ストリーミング要求に対しては、SDK が「この要求は10分を超える可能性がある」旨の警告を出し、ストリーミングの利用を推奨します。私はこの警告を、恥ずかしながら長いあいだログの中で見過ごしていました。警告は正しかったのです。
なぜタイムアウトのリトライだけが特別に危ないのか
一時エラーのリトライが安全なケースと、危険なケースを分けて考えると、設計の勘所が見えてきます。
失敗の種類 サーバー側の状態 リトライの安全性
429 / 529(過負荷) 要求は受理されず処理されていない 安全。再送すれば1回分だけ課金
接続確立の失敗 要求がサーバーに届いていない 安全。まだ何も生成されていない
読み取りタイムアウト 要求は処理中、生成が続いている可能性大 危険。完走すれば二重課金
ポイントは、Messages API の単発リクエストには、Stripe のような冪等キー(同じキーの再送を1回として扱う仕組み)が用意されていないことです。私たちがローカルで「これは再送だ」と管理しない限り、サーバーから見た2本の要求は完全に別物です。だからこそ、タイムアウトが絡む再送は、SDK に丸投げしてはいけない のです。
対策1: 長い出力はストリーミングに切り替える
もっとも効くのは、そもそも読み取りタイムアウトを起こさないことです。ストリーミングにすると、サーバーは生成の途中経過を細切れのイベントで送り続けます。接続は常にデータが流れている状態になり、10分の読み取りタイムアウトの壁に当たりません。
from anthropic import Anthropic
client = Anthropic()
def summarize_streaming (system_prompt: str , user_text: str ) -> str :
"""長い出力はストリーミングで受ける。読み取りタイムアウトを回避する。"""
chunks = []
with client.messages.stream(
model = "claude-haiku-4-5" ,
max_tokens = 16000 ,
system = system_prompt,
messages = [{ "role" : "user" , "content" : user_text}],
) as stream:
for text in stream.text_stream:
chunks.append(text)
final = stream.get_final_message()
# 途切れの検知も stop_reason で必ず行う
if final.stop_reason == "max_tokens" :
raise RuntimeError ( "出力が max_tokens で打ち切られました。続きの取得が必要です" )
return "" .join(chunks)
ストリーミングには副次的な利点もあります。途中で接続が切れても、そこまでのテキストが chunks に残るため、まるごと捨てずに済みます。私は max_tokens が 8,000 を超えるタスクは、原則すべてこの形にしました。判断基準は単純で、「10分を超えうる長さかどうか」の一点です。
対策2: 非ストリーミングを使うなら timeout と max_retries を明示する
要約の後処理など、どうしても非ストリーミングで書きたい箇所は残ります。その場合は既定値に頼らず、その呼び出しに対して明示的に上書きします。with_options を使うと、その1回だけ設定を差し替えられます。
import time
from anthropic import Anthropic, APITimeoutError, APIConnectionError
client = Anthropic()
def create_long_safe (system_prompt: str , user_text: str , job_id: str , ledger) -> str :
"""非ストリーミングの長い要求を、二重課金を避けて実行する。"""
# すでに走らせた記録があれば、盲目的な再送を止める
if ledger.is_inflight_or_done(job_id):
raise RuntimeError ( f "job { job_id } は既に実行済み/実行中。手動確認が必要です" )
ledger.mark_inflight(job_id)
# このタスクは長いので timeout を十分に広げ、自動リトライは切る
scoped = client.with_options( timeout = 1800.0 , max_retries = 0 )
try :
msg = scoped.messages.create(
model = "claude-haiku-4-5" ,
max_tokens = 16000 ,
system = system_prompt,
messages = [{ "role" : "user" , "content" : user_text}],
)
except APITimeoutError:
# タイムアウトは「未確定」。自分では絶対に自動再送しない
ledger.mark_uncertain(job_id)
raise
except APIConnectionError:
# 接続確立の失敗は未処理が濃厚。ここは再送してよい種類
ledger.mark_failed(job_id)
raise
ledger.mark_done(job_id, input_tokens = msg.usage.input_tokens,
output_tokens = msg.usage.output_tokens)
return msg.content[ 0 ].text
max_retries=0 が肝です。SDK の自動リトライを止め、再送するかどうかを自分の判断に取り戻します。APITimeoutError は「未確定」として記録するだけにとどめ、決して即座に投げ直しません。タイムアウトの裏でサーバーがまだ生成している可能性がある以上、盲目的な再送は二重課金へまっすぐ進むからです。
一方で APIConnectionError(接続そのものが確立できなかった失敗)は、要求が届いていない公算が高いので、後述の台帳を確認したうえで安全に再送できます。同じ「失敗」でも、扱いをはっきり分けるのが要点です。
対策3: ローカル台帳で「再送してよいか」を機械が答える
最後のピースは、無人でも「その仕事をもう一度投げてよいか」を判断できる状態を持つことです。Messages API に冪等キーがない以上、その役目はこちらで持つしかありません。私は JSONL の軽い台帳を1つ用意し、要求の一生を記録するようにしました。
import json
import time
from pathlib import Path
class JobLedger :
"""要求の一生を追う軽量台帳。二重投入の最後の砦。"""
def __init__ (self, path: str ):
self .path = Path(path)
self ._state = {}
if self .path.exists():
for line in self .path.read_text().splitlines():
rec = json.loads(line)
self ._state[rec[ "job_id" ]] = rec[ "status" ]
def _append (self, job_id: str , status: str , ** extra):
rec = { "job_id" : job_id, "status" : status, "ts" : time.time(), ** extra}
with self .path.open( "a" ) as f:
f.write(json.dumps(rec, ensure_ascii = False ) + " \n " )
self ._state[job_id] = status
def is_inflight_or_done (self, job_id: str ) -> bool :
# uncertain と done と inflight は、機械が勝手に再送してはいけない状態
return self ._state.get(job_id) in { "inflight" , "uncertain" , "done" }
def mark_inflight (self, job_id): self ._append(job_id, "inflight" )
def mark_uncertain (self, job_id): self ._append(job_id, "uncertain" )
def mark_failed (self, job_id): self ._append(job_id, "failed" )
def mark_done (self, job_id, ** usage): self ._append(job_id, "done" , ** usage)
肝は uncertain(未確定)という状態を独立して持つことです。タイムアウトした要求を failed に丸めてしまうと、翌朝のリトライ処理が「失敗なら再送」と判断して二重課金を再生産します。uncertain は「人が確認するまで機械は触るな」の印です。私はこの状態に落ちた要求だけを朝のレビューキューに流し、実際に課金が二重に立っていないかを usage で突き合わせてから、手で再送するかどうかを決めています。
台帳に出力・入力トークンを記録しておくと、後からコスト側の実測にも使えます。トークン単位のコスト集計そのものは、別記事のClaude API の使用量を用途別に束ねるコスト台帳の作り方 で詳しく扱っているので、台帳の器を共有すると運用が一本にまとまります。
実際にどれだけ効いたか
この3点を夜間バッチに入れる前後で、同じ7日間・同じ処理件数のログを比べました。
指標 導入前 導入後
10分超で終わった要求の割合 約2% 約0%(ストリーミング化で消失)
タイムアウト起因の自動リトライ回数 / 週 28回 0回
二重課金と判定できた要求 / 週 19回 0回
朝のレビューキューに滞留した uncertain — 週0〜2件(手動確認で解消)
数字だけ見れば週19回、率にして2%弱の話です。けれど無人で回している以上、この2%は誰にも気づかれずに毎週積み上がっていました。個人開発のコストは、こういう「見えないところで静かに漏れる」種類が一番やっかいだと、私はあらためて感じました。
なお、そもそも大量の要求をまとめて処理するなら、非同期の Message Batches に寄せてしまう手もあります。バッチは応答をリアルタイムに待たないため、この10分の壁の問題自体が構造的に起きません。使い分けの判断はMessage Batches API の非同期処理でコストを設計する にまとめてあります。リトライ全般のバックオフ設計はRetry-After ヘッダーを優先した指数バックオフの実装 が土台になります。
まず試す一歩
もし今、非ストリーミングで max_tokens を1万以上に設定した呼び出しが1つでもあるなら、まずその1本をストリーミングに書き換えてみてください。messages.create を messages.stream に替え、text_stream を回すだけです。10分の壁を踏む可能性が消えれば、二重課金の入り口はその場で閉じます。
私自身、この問題に気づくまでは「タイムアウトしたら再送」を疑いもなく信じていました。けれど無人運用では、その素直さが静かに課金を重ねます。再送してよいかを機械が答えられる状態を持つこと。今日はそこから始めていただければ、と思います。お読みいただきありがとうございました。