●SONNET — Claude Sonnet 5 is now the default model in Claude Code, with a native 1M-token context window and introductory pricing through August 31●CHROME — Claude in Chrome reaches general availability, letting you hand browser work directly to Claude●COWORK — Cowork expands to mobile and web so sessions and files follow you across devices, starting in beta for Max users●DATAVIZ — Claude Code adds a /dataviz skill offering guidance for designing charts and dashboards●AGENTS — Agent workflows gain background notifications, draft PR handoff, and improved failover●ENTERPRISE — Claude Enterprise adds richer admin analytics, model-level entitlements, and spend alerts●SONNET — Claude Sonnet 5 is now the default model in Claude Code, with a native 1M-token context window and introductory pricing through August 31●CHROME — Claude in Chrome reaches general availability, letting you hand browser work directly to Claude●COWORK — Cowork expands to mobile and web so sessions and files follow you across devices, starting in beta for Max users●DATAVIZ — Claude Code adds a /dataviz skill offering guidance for designing charts and dashboards●AGENTS — Agent workflows gain background notifications, draft PR handoff, and improved failover●ENTERPRISE — Claude Enterprise adds richer admin analytics, model-level entitlements, and spend alerts
A Long Non-Streaming Response Was Billed Twice Past the 10-Minute Wall: Redesigning the SDK's Default Timeout and Retries
The Anthropic SDK's default 10-minute timeout and two automatic retries can silently re-run a long non-streaming response and bill you twice. Here is how the trap works, and how to close it with streaming, explicit timeout/max_retries, and a small local ledger — with measured before/after numbers.
One week, the cost of my nightly batch crept up for no reason I could see. The number of summaries generated was the same as the week before, the number of articles processed was the same, yet the api-sdk line on the bill kept climbing. It took me half a day to find the culprit, and it was hiding somewhere I never thought to look.
I had a summarization task with max_tokens raised to 16,000, and I had written it the plain way: call client.messages.create(...), take the text back. During busy hours, though, that generation occasionally took nine to eleven minutes. And the Anthropic SDK's default request timeout is exactly ten minutes.
Cross that line by a single second and the SDK's read timeout fires. The SDK treats it as a transient failure and, under its default retry policy (up to two attempts), sends the same request again. Here is the trap: the first request is still generating on the server, and if it finishes, you are billed for the output tokens it produced. Then the retry runs as a second, independent request and bills you again. I receive one response, but I pay for two. That was the quiet cost creep.
What follows is a breakdown of that mechanism down to the SDK's behavior, and the three design changes that stop the double charge at the root — with the code I actually put into my nightly batch. If you run unattended jobs across a few sites as an indie developer, this is exactly the kind of spend you can keep paying without ever noticing.
Misreading the defaults bites hardest when nobody is watching
Start with the two defaults the SDK carries silently. In the Anthropic Python SDK, both of these are in effect the moment you construct a client.
Setting
Default
What it means for unattended jobs
timeout
10 minutes (600s)
A long generation that exceeds 10 minutes triggers a read timeout
max_retries
2
On transient errors, including timeouts, the same request is resent up to twice
Automatic retries are a good feature in general. For errors like 429 (rate limit), 5xx, or a dropped connection — the "just send it again and it will go through" kind — exponential backoff with jitter works well. The problem is that a read timeout is not that kind of error.
A timeout is only a signal that the client gave up waiting. The server has no idea about your side and keeps generating. Messages API billing is charged against the output tokens that get produced. So the first request, even if you never receive it, is billed once it completes. Layer a retry on top and you pay twice for the same generation.
The SDK is aware of this danger. For a non-streaming request with a large max_tokens, it emits a warning that the request may exceed ten minutes and recommends streaming instead. I am a little embarrassed to admit I had been scrolling past that warning in my logs for a long time. The warning was right.
Why timeout retries specifically are the dangerous ones
Separating the safe retries from the dangerous ones is where the design clicks into place.
Failure type
Server-side state
Retry safety
429 / 529 (overloaded)
Request rejected, not processed
Safe. A resend is billed once
Connection failure
Request never reached the server
Safe. Nothing was generated yet
Read timeout
Request in flight, likely still generating
Dangerous. Double-billed if it completes
The key fact is that a single Messages API request has no idempotency key — no Stripe-style mechanism that folds a re-sent request into one. Unless we track "this is a resend" ourselves, the two requests are entirely separate as far as the server is concerned. That is precisely why retries that involve a timeout must not be delegated to the SDK.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A breakdown of how the SDK's default 10-minute timeout plus two automatic retries quietly re-run a long non-streaming request and double the bill
✦Working code that stops the double charge with streaming, explicit timeout and max_retries, and an idempotent local ledger
✦The exact settings that took duplicate-billing from roughly 2% to zero in a nightly batch, plus how to verify it
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The most effective move is to never trigger the read timeout in the first place. With streaming, the server sends generation progress as a steady flow of small events. The connection always has data moving through it, so it never hits the 10-minute read-timeout wall.
from anthropic import Anthropicclient = Anthropic()def summarize_streaming(system_prompt: str, user_text: str) -> str: """Receive long output over a stream to avoid the read timeout.""" 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() # Always check stop_reason for truncation, too if final.stop_reason == "max_tokens": raise RuntimeError("Output was cut off at max_tokens; continuation needed") return "".join(chunks)
Streaming has a second benefit: if the connection drops midway, the text collected so far is still in chunks, so you do not throw away the whole thing. I made every task with max_tokens above 8,000 use this shape by default. The rule is simple — if the length could plausibly cross ten minutes, stream it.
Fix 2: if you stay non-streaming, set timeout and max_retries explicitly
Some places still want to stay non-streaming, like a short post-processing pass. For those, stop relying on the defaults and override them for that specific call. with_options lets you swap settings for a single invocation.
from anthropic import Anthropic, APITimeoutError, APIConnectionErrorclient = Anthropic()def create_long_safe(system_prompt: str, user_text: str, job_id: str, ledger) -> str: """Run a long non-streaming request without risking a double charge.""" # If there is already a record of this job, refuse a blind resend if ledger.is_inflight_or_done(job_id): raise RuntimeError(f"job {job_id} already ran / is running; needs a human check") ledger.mark_inflight(job_id) # This task is long: widen the timeout, turn OFF automatic retries 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: # A timeout is "undetermined". Never auto-resend it myself ledger.mark_uncertain(job_id) raise except APIConnectionError: # A connect failure most likely never processed; safe to resend later 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 is the crux. It turns off the SDK's automatic retries and takes the decision to resend back into my own hands. An APITimeoutError is only recorded as "undetermined" — it is never thrown straight back at the API, because while a timeout is unresolved the server may still be generating, and a blind resend walks right into the double charge. An APIConnectionError, by contrast, most likely never reached the server, so it can be resent safely after a ledger check. Same word, "failure" — very different handling.
Fix 3: let a local ledger answer "is a resend allowed?"
The last piece is holding enough state that, even unattended, the system can answer whether a job may be sent again. With no idempotency key on the Messages API, that responsibility is ours. I keep one light JSONL ledger that records the life of each request.
import jsonimport timefrom pathlib import Pathclass JobLedger: """A lightweight ledger tracking each request's life. The last line of defense.""" 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: # inflight / uncertain / done: states the machine must not resend on its own 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)
The point is holding uncertain as its own distinct state. If you fold a timed-out request into failed, the next morning's retry pass reads "failed means resend" and reproduces the double charge. uncertain is the marker for "the machine must not touch this until a human looks." I route only those requests into a morning review queue, reconcile them against usage to see whether a charge really landed twice, and only then decide by hand whether to resend.
Recording input and output tokens in the ledger also gives you real numbers on the cost side later. The per-token cost accounting itself is covered in building a cost ledger that buckets Claude API usage by purpose, so sharing the same ledger keeps operations in one place.
How much it actually helped
I compared logs over the same seven days and the same volume of work, before and after these three changes.
Metric
Before
After
Requests finishing past 10 minutes
~2%
~0% (gone once streamed)
Timeout-driven automatic retries / week
28
0
Requests identified as double-billed / week
19
0
"uncertain" left in the morning review queue
—
0–2 / week (cleared by hand)
On paper it is 19 requests a week, a little under 2%. But because it runs unattended, that 2% had been quietly stacking up every week with no one to notice. The trickiest kind of indie-development cost, I was reminded, is the sort that leaks silently where you cannot see it.
If you have even one call today that sets max_tokens above 10,000 without streaming, rewrite that one call first. Swap messages.create for messages.stream and iterate over text_stream. Once the possibility of touching the 10-minute wall is gone, the entrance to the double charge closes on the spot.
Until I hit this myself, I had trusted "if it times out, resend it" without a second thought. In unattended operation, that trust quietly compounds into extra charges. Hold enough state that the machine can answer whether a resend is allowed. Starting there today would be my one suggestion. Thank you for reading.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.