LLM timeout check
/tools/llm-timeout-check/How long one agent task can run before your SDK and loop settings stop it, when the model is silent, trickling or overloaded, and what to set when that is too long.
How long one agent task can run before your SDK and loop settings stop it, when the model is silent, trickling or overloaded, and what to set when that is too long.
How long can one agent task run before anything in its settings stops it, and what should you set if that’s too long? It checks three ways a model call goes wrong:
| Failure | What the provider does | What stops it by default |
|---|---|---|
| Silent | Accepts the connection and sends nothing | The read timeout, once per attempt, then the retries |
| Trickling | Answers 200 and streams slowly | Only max_tokens: every token restarts the read timeout |
| Overloaded | Refuses at once with 429 or 529 | The SDK’s backoff between retries |
Each is worked out for one attempt, one call with its retries, and one task of up to the turn limit, and judged against how long the task may take: a person in a chat, or a job’s own deadline. Where it’s too long, the tool names the one setting that fixes it.
Stack fills in your SDK’s defaults, so you only type what you changed:
| Stack | Read timeout | Retries | Turns |
|---|---|---|---|
| Anthropic or OpenAI Python SDK, 1.13.0 and 3.28.0 | 600 s | 2 | left as it is: neither SDK has a turn limit, so enter your loop’s |
| OpenAI Agents SDK 0.23.1 | 600 s | 2, from the OpenAI client it creates | 10, its max_turns |
| Custom |
The two Python SDKs share one choice because their defaults are the same. Edit any of the numbers afterwards and Stack switches to Custom, or to whichever stack the numbers still match. None of the three sets an attempt deadline.
Say you run a support chat. Your agent uses the Anthropic Python SDK and you never changed its timeout. It makes up to 10 model calls to answer one question, and a customer will wait about 2 minutes before giving up.
1. Enter what you run.
| Input | You enter | Why |
|---|---|---|
| Stack | Anthropic or OpenAI Python SDK | It fills Read timeout with 600 and Retries with 2, the SDK’s defaults |
| Attempt deadline | 0 | You don’t wrap the call in your own time limit |
| Turns | 10 | Your agent stops after 10 calls |
| max_tokens | 4096 | What you pass to the API |
| Acceptable wait | 120 | 2 minutes, the customer’s patience |
2. Press Run, and read the top line.
Worst case for one task5.7 hA model that trickles its answer can keep a 10-turn task running this long, which is over the 2 min you can wait.In plain words: if the model starts answering very slowly, your agent can keep a customer waiting almost six hours, and nothing in your settings stops it.
3. Read what to set, worst first:
| Failure | What to set | Worst case | What ends it |
|---|---|---|---|
| Trickling | Set an attempt deadline of 3 s or less. | 5.7 h | Only max_tokens ends it, because every token restarts the read timeout. |
| Silent | Set an attempt deadline of 3 s or less. | 5 h | The read timeout ends each attempt, then the SDK retries. |
| Overloaded | Nothing. It fits the wait. | 15 s | The SDK gives up after its retries and their backoff. |
A model that says nothing is cut off after 600 s and retried twice, on every turn. A slow answer is ended only by its length. A model that refuses at once costs only the short waits between retries, so that one is fine. The fix for both slow cases is the same: give each attempt its own time limit.
4. Read the numbers under it. For the worst case, trickling:
One attempt 34.1 min 4,096 tokens at 2 tokens a second.One call, with its retries 34.1 min Not retried, because the provider answered 200.Cost of the worst-case trickle $1.01 10 calls of 4,700 input tokens, at $4 and $20 per million tokens, ...5. Read the chart to see why. Look at the Trickling column:
The read timeout never helps here. It only fires when the model goes quiet for 600 s, and a slow stream is never quiet that long: every token restarts the clock.
6. Try the fix and Run again. Put 3 in Attempt deadline. Every mark drops under the red line, the worst case
becomes 1.8 min, and the top line turns green.
If 3 seconds is too short for your model to answer, that tells you something too: 10 turns with 2 retries each
can’t fit in 2 minutes. Lower Turns to 4 and Retries to 1, and the deadline it suggests rises to 14 s.
For a background job where an hour is fine, set Acceptable wait to 3600. The same defaults are still far too
long, 5.7 hours, but now an attempt deadline of about 2 minutes is enough.
timeout=600 is an httpx2 timeout whose read part is the longest wait for the next chunk. Measured
against a local server with anthropic 1.13.0, timeout=3.0 let a stream of 12 tokens 1.5 s apart run 18 s
without an error.max_tokens / 2 seconds unless the attempt deadline
cuts it. A cut attempt is retried like a timeout, the conservative reading. A read timeout shorter than the gap
between tokens fires mid-stream, after the 200, as a transport error the SDK doesn’t retry.turns calls at the worst case. That assumes the agent loop starts another turn after a
failed call, as many hand-written loops do. A loop that stops at the first failure takes one call.(wait - turns x backoff) / ((retries + 1) x turns), rounded down.retry-after header from the provider replaces it, and the anthropic SDK accepts
one of up to 4,294,967 s._constants.py:
DEFAULT_TIMEOUT = httpx2.Timeout(timeout=10 * 60, connect=5.0), DEFAULT_MAX_RETRIES = 2, backoff 0.5 s to
8 s.run_config.py:
DEFAULT_MAX_TURNS = 10.mainlog-code/agent-cost-control, measured the SDK behaviour above.index.ts/** * LLM timeout check: how long one agent task can take before anything in its settings stops it, for the three ways * a model call goes wrong, judged against how long the caller can wait. * * - **Silent.** The provider accepts the connection and sends nothing. Each attempt lasts the read timeout (or the * attempt deadline, if shorter), and the SDK retries, waiting its backoff between attempts: 0.5 s doubling to 8 s * in the anthropic and openai Python SDKs, before jitter. Jitter only shortens a wait, so the worst case is the * unshortened one. * - **Trickling.** The provider answers 200 and streams slowly. The read timeout bounds each gap between tokens, * not the call, so a stream that keeps sending runs until `max_tokens`, unless an attempt deadline cuts it. A cut * attempt is a failure and is retried, the conservative reading. A gap longer than the read timeout fires it * mid-stream, after the 200, where the anthropic SDK raises the transport's own ReadTimeout and does not retry. * - **Overloaded.** The provider refuses at once with 429 or 529. Each retry waits the backoff. * * A task is up to `turns` calls, each at its worst case, which assumes the agent loop starts another turn after a * failed call, as many hand-written loops do. Fixed, and stated in the inputs: a trickle of 2 tokens a second, the * rate of the slow streams in the post this tool came from. * * The output leads with the worst case for one task, as a banner only when it is over the reader's wait, then the * three failures side by side, the chart, and what it assumes. The cost of the worst-case trickle comes last, for one * input size and one price, as a sense of scale. Whether a * provider bills an attempt that was cut off is not documented by any provider checked (Anthropic, OpenAI, AWS, * Google, on 2026-10-11), so it gives both bounds. */import type { Cell, Output, Tool } from "@toolbench/sdk";
type Input = { /** Which SDK's defaults filled the form. The presets live in tool.json; the numbers below are what counts. */ stack: string; read: number; retries: number; deadline: number; turns: number; maxtokens: number; patience: number;};
class InputError extends Error { readonly input: string; constructor(message: string, input: string) { super(message); this.input = input; }}
/** The anthropic and openai Python SDKs' backoff before jitter. */export const BACKOFF = 0.5;export const BACKOFF_MAX = 8;/** The trickle's rate, tokens a second. */export const TRICKLE = 2;/** For the cost line only: input tokens per call, and Claude Opus 5.5's list prices on 2026-10-11, $ per million. */export const INPUT_TOKENS = 4700;export const PRICE_IN = 4;export const PRICE_OUT = 20;
/** The waits before retries 1..n, before jitter. */export function backoffs(retries: number): number[] { return Array.from({ length: retries }, (_, n) => Math.min(BACKOFF * 2 ** n, BACKOFF_MAX));}
export type Shape = { name: "Silent" | "Trickling" | "Overloaded"; /** One attempt, seconds; 0 when the refusal is immediate. */ attempt: number; /** One call with its retries. */ call: number; /** One task of up to `turns` calls. */ task: number; /** What ends one call. */ endedBy: string; /** The same, in a few words, for a table column: "max_tokens only", "retry limit". */ stop: string; /** Output tokens one attempt streams. */ streamed: number; /** Attempts per call that stream output. */ attempts: number;};
export function shapes(i: Input): Shape[] { const waits = backoffs(i.retries).reduce((a, b) => a + b, 0); const tries = i.retries + 1; const cut = i.deadline > 0 ? i.deadline : Number.POSITIVE_INFINITY;
const hang = Math.min(i.read, cut); const silent: Shape = { name: "Silent", attempt: hang, call: tries * hang + waits, task: 0, endedBy: cut < i.read ? "The attempt deadline ends each attempt, then the SDK retries." : "The read timeout ends each attempt, then the SDK retries.", stop: `${cut < i.read ? "attempt deadline" : "read timeout"}${i.retries ? ", then retries" : ""}`, streamed: 0, attempts: tries, };
const full = i.maxtokens / TRICKLE; let trickle: Shape; if (1 / TRICKLE >= i.read) { trickle = { name: "Trickling", attempt: i.read, call: i.read, task: 0, endedBy: "The read timeout fires mid-stream, and the SDK does not retry it.", stop: "read timeout, not retried", streamed: 0, attempts: 1, }; } else if (cut < full) { trickle = { name: "Trickling", attempt: cut, call: tries * cut + waits, task: 0, endedBy: "The attempt deadline cuts each attempt, then the SDK retries.", stop: `attempt deadline${i.retries ? ", then retries" : ""}`, streamed: Math.floor(cut * TRICKLE), attempts: tries, }; } else { trickle = { name: "Trickling", attempt: full, call: full, task: 0, endedBy: "Only max_tokens ends it, because every token restarts the read timeout.", stop: "max_tokens only", streamed: i.maxtokens, attempts: 1, }; }
const overloaded: Shape = { name: "Overloaded", attempt: 0, call: waits, task: 0, endedBy: i.retries > 0 ? "The SDK gives up after its retries and their backoff." : "The first refusal ends it.", stop: i.retries > 0 ? "retry limit" : "first refusal", streamed: 0, attempts: 0, };
return [silent, trickle, overloaded].map((s) => ({ ...s, task: s.call * i.turns }));}
/** The one setting that brings this shape under the acceptable wait, or that it already fits. */export function advice(i: Input, s: Shape): { text: string; ok: boolean } { if (s.task <= i.patience) return { text: `It stops after ${dur(s.task)}, inside the wait.`, ok: true }; if (s.name === "Overloaded") return { text: "Use fewer retries or turns.", ok: false }; const waits = backoffs(i.retries).reduce((a, b) => a + b, 0); const perAttempt = (i.patience - waits * i.turns) / ((i.retries + 1) * i.turns); if (perAttempt >= 1) return { text: `Set an attempt deadline of ${dur(Math.floor(perAttempt))} or less.`, ok: false }; return { text: "Use fewer retries or turns, then set an attempt deadline.", ok: false };}
/** 4 s, 2.5 min, 3 h, 28.4 days: the unit a person would say it in. */export function dur(s: number): string { if (s < 60) return `${trim(s)} s`; if (s < 3600) return `${trim(s / 60)} min`; if (s < 86400 * 2) return `${trim(s / 3600)} h`; return `${trim(s / 86400)} days`;}const trim = (x: number) => (Math.abs(x - Math.round(x)) < 0.05 ? Math.round(x).toString() : x.toFixed(1));const int = (x: number) => Math.round(x).toLocaleString("en-US");const money = (x: number) => (x === 0 ? "$0" : x < 0.01 ? "under $0.01" : `$${x.toFixed(2)}`);const price = (input: number, output: number) => (input * PRICE_IN + output * PRICE_OUT) / 1e6;
/** Who causes each worst case, as the verdict says it. */const WHO: Record<Shape["name"], string> = { Silent: "A model that sends nothing", Trickling: "A model that trickles its answer", Overloaded: "A model that refuses every call",};const RANGES: [Exclude<keyof Input, "stack">, number, number, string][] = [ ["read", 0.1, 86400, "The read timeout is between 0.1 s and a day."], ["retries", 0, 20, "Retries are between 0 and 20."], ["deadline", 0, 86400, "The attempt deadline is between 0 (none) and a day."], ["turns", 1, 1000, "Turns are between 1 and 1,000."], ["maxtokens", 1, 1000000, "max_tokens is between 1 and 1,000,000."], ["patience", 1, 86400, "The wait is between 1 s and a day."],];
export default { run(raw: Input) { try { const i = { ...raw }; for (const [k, lo, hi, msg] of RANGES) { const v = Number(i[k]); if (!(v >= lo && v <= hi)) throw new InputError(msg, k); i[k] = v; } if (!Number.isInteger(i.retries)) throw new InputError("Retries are a whole number.", "retries"); if (!Number.isInteger(i.turns)) throw new InputError("Turns are a whole number.", "turns");
const sh = shapes(i); const worst = sh.reduce((a, s) => (s.task > a.task ? s : a)); const over = worst.task > i.patience; const turns = `${i.turns} turn${i.turns === 1 ? "" : "s"}`;
/* * ⚠️ A banner only when the task can outlast the wait the reader typed: that limit is theirs, so its tone is * earned. It carries the worst case and the one setting that bounds it, and nothing else; the table under it * says which failure and why. Within the wait, the same line is the quiet answer. */ const fix = advice(i, worst); /* A failure the worst case's setting does not fix gets its own clause, so no row over the wait goes unanswered. */ const also = sh .filter((s) => s.task > i.patience && advice(i, s).text !== fix.text) .map((s) => ` ${s.name}: ${advice(i, s).text.charAt(0).toLowerCase()}${advice(i, s).text.slice(1)}`); const head: Output = over ? { kind: "fields", role: "verdict", fields: [ { label: `Over your ${dur(i.patience)} wait`, role: "verdict", value: `Up to ${dur(worst.task)} for one ${i.turns}-turn task`, note: `${fix.text}${also.join("")}`, tone: "bad", }, ], } : { kind: "fields", role: "answer", fields: [ { label: "Worst case for one task", value: dur(worst.task), note: `${WHO[worst.name]} can keep a ${i.turns}-turn task running this long, inside the ${dur(i.patience)} you can wait.`, }, ], };
/* * The three failures side by side, worst first; a stable sort keeps silent ahead of trickling on a tie. One * attempt, one call with its retries, one task, and what stops it in a few words, so the columns fit a phone. * Only the task column is toned, and only against the reader's wait. */ const rows: Cell[][] = [...sh] .sort((a, b) => b.task - a.task) .map((s) => [ s.name, { text: s.attempt > 0 ? dur(s.attempt) : "0 s", mono: true }, { text: dur(s.call), mono: true }, { text: dur(s.task), mono: true, ...(s.task > i.patience ? { tone: "bad" as const } : {}) }, s.stop, ]); const matrix: Output = { kind: "table", role: "comparison", caption: `Each failure, for one task of up to ${turns}, worst first`, columns: [ { label: "Failure", wrap: "prose", rowHeader: true }, { label: "Attempt", align: "end", mono: true, wrap: "none" }, { label: "Call", align: "end", mono: true, wrap: "none" }, { label: "Task", align: "end", mono: true, wrap: "none" }, { label: "Stopped by", wrap: "prose" }, ], rows, };
const tr = sh[1]!; const finished = tr.streamed === i.maxtokens && tr.attempts === 1; const billed = tr.streamed > 0 ? i.turns : 0; const low = finished ? price(i.turns * INPUT_TOKENS, i.turns * i.maxtokens) : 0; const high = price(billed * tr.attempts * INPUT_TOKENS, billed * tr.attempts * tr.streamed); const basis = `${i.turns} call${i.turns === 1 ? "" : "s"} of ${int(INPUT_TOKENS)} input tokens, at $${PRICE_IN} and $${PRICE_OUT} per million tokens, Claude Opus 5.5's list prices on 2026-10-11.`; /* What the numbers assume, and the cost as a sense of scale, last and quiet. */ const method: Output = { kind: "fields", role: "method", fields: [ { label: "Assumes", mono: false, value: `A trickle of ${TRICKLE} tokens a second, and a loop that starts another turn after a failed call.`, }, { label: "Cost of the worst-case trickle", value: low === high ? money(low) : `${money(low)} to ${money(high)}`, note: low === high ? basis : `${basis} The higher figure also bills the attempts that were cut off. No provider says whether it does.`, }, ], };
const pt = (v: number) => (v > 0 ? v : null); const chart: Output = { kind: "series", chart: { xLabel: "Failure", yLabel: "Worst case", yUnit: "s", yScale: "log", x: sh.map((s) => s.name), series: [ { label: "One attempt", shape: "points", points: sh.map((s) => pt(s.attempt)), notes: sh.map((s) => (s.attempt > 0 ? dur(s.attempt) : "refused at once")), }, { label: "One call, with retries", shape: "points", points: sh.map((s) => pt(s.call)), notes: sh.map((s) => dur(s.call)), }, { label: `One task, ${turns}`, shape: "points", points: sh.map((s) => pt(s.task)), notes: sh.map((s) => dur(s.task)), }, ], /* The reader's wait, and nothing else: a fixed one-hour line was a threshold nobody set. */ thresholds: [{ y: i.patience, label: `you can wait ${dur(i.patience)}`, tone: "bad" }], }, };
return { kind: "group", parts: [head, matrix, chart, method] }; } catch (error) { if (error instanceof InputError) return { kind: "error", message: error.message, input: error.input }; throw error; } },} satisfies Tool<Input>;
Timeouts, retries and budgets for model calls that finish too slowly, produce unusable answers or keep repeating themselves. With the real Anthropic SDK, timeout=3.0 let an 18-second stream finish. In a simulated support chat, with invented failure rates, five checks took the conversations waiting over two minutes from 249 to 0.
What it takes to make agents reliable enough to run in production.

Timeouts, retries and budgets for model calls that finish too slowly, produce unusable answers or keep repeating themselves. With the real Anthropic SDK, timeout=3.0 let an 18-second stream finish. In a simulated support chat, with invented failure rates, five checks took the conversations waiting over two minutes from 249 to 0.
What it takes to make agents reliable enough to run in production.