Handling Tool Errors & Retries
Introduction
Every lesson so far assumed the happy path: the tool runs, returns a result, the agent continues. Production is not the happy path. APIs rate-limit you, networks drop, the model passes a bad argument, a tool throws, a sandbox times out. Studies find agents retry tool calls 15–30% of the time. Errors aren't the exception — they're the common case.
And here's the uncomfortable truth about agent reliability:
"The production harness accounts for ~98% of agent reliability" — the validation, retry, and recovery logic lives in your orchestration code, not in a cleverer prompt.
Error handling has two goals, and you must hit both:
- Don't crash. One failing tool call must not take down the whole agent loop.
- Let the agent decide. Give the model (or your code) enough information to recover — retry, fix the input, switch tools, or give up gracefully.
In this lesson:
- The first rule — never drop an error (
is_error), never swallow it - Error messages are prompts — make them actionable
- Two failure kinds, two retry layers — transient (code backoff) vs semantic (the model adapts)
- The idempotency trap, circuit breakers & graceful degradation, and the retry-spiral pitfalls
Scope: this builds on the L121 (Tools) tool lifecycle, L123 (Parallel & Sequential Tool Calls) parallel calls, and L124 (Code Execution & Sandboxing)'s sandbox (whose code also fails). It's the reliability capstone before L126 (Hands-On: Web Search + DB + Calculator)'s hands-on build.

The First Rule: Never Drop an Error
When a tool fails, you have three options, and two of them are wrong. You can let the exception propagate (the agent crashes), you can catch it and return something fake like "" (you swallow it), or you can catch it and tell the model. Only the third is correct.
The mechanism is the is_error flag on a tool_result:
for tool in tool_uses:
try:
result = execute(tool.name, tool.input)
results.append({"type": "tool_result", "tool_use_id": tool.id,
"content": str(result)})
except Exception as e:
# DON'T crash the loop. DON'T swallow it. Hand the error BACK to the model.
results.append({"type": "tool_result", "tool_use_id": tool.id,
"is_error": True,
"content": f"{type(e).__name__}: {e}"}) # actionable text
messages.append({"role": "user", "content": results})
# The model reads is_error like a ReAct observation (L111) and tries again.
# (In a parallel batch (L123), a failed call STILL needs its own is_error result.)Two anti-patterns this avoids:
- Crashing — an unhandled exception in one tool ends the entire run. A single flaky API shouldn't be fatal.
- Swallowing — the worse one. If you return
""on failure, the model thinks the call succeeded and hallucinates over the gap: "The weather is sunny and 75°F!" — confidently wrong. A visible error is recoverable; an invisible one is a silent lie.
Remember
is_errorfrom L123 (Parallel & Sequential Tool Calls)'s parallel batches: everytool_useneeds a matchingtool_result, and a failed one is still atool_result— just withis_error: true. Never simply omit it.
Error Messages Are Prompts
Once you've decided to hand the error back, what you say is everything. The model treats the error like a ReAct observation (L111) — it reads it and reasons about what to do. So an error message is a prompt, exactly like a tool description was in L122 (Designing Robust Tool Interfaces). Write it for the model:
# ❌ vague — the model has nothing to act on
{"type": "tool_result", "tool_use_id": id, "is_error": True, "content": "Error"}
# ✅ actionable — an Error Type + exactly how to fix it. An error message is a PROMPT.
{"type": "tool_result", "tool_use_id": id, "is_error": True,
"content": "InvalidCity: 'Springfield, ZZ' not found. "
"Provide a US city as 'City, ST' — e.g. 'Springfield, IL'."}A good error message has two parts: an error type (InvalidCity, RateLimited, Timeout — a label the model can pattern-match) and a descriptive, actionable explanation (what went wrong and how to fix it). The payoff is large and well-documented: giving the model one correction attempt with a clear message resolves most argument/format errors on the first retry.
Compare the two outcomes: "Error" leaves the model guessing (it'll often retry the exact same failing call). "InvalidCity: … use 'City, ST'" tells it precisely what to change — and it does. Vague errors cause retry loops; actionable errors cause recovery.
Two Kinds of Failure, Two Layers of Retry
This is the core mental model of the whole lesson. Not all errors are retried the same way — because not all errors are the same kind.
| Transient (infrastructure) | Semantic (logical) | |
|---|---|---|
| Examples | 429 rate limit, 5xx, timeout, dropped connection | bad arguments, 404 not found, wrong tool, a code traceback |
| Will waiting fix it? | Yes — the service recovers | No — the same call fails forever |
| Who retries? | Your code — silently, with backoff | The model — by changing the call |
| Mechanism | exponential backoff + jitter | return is_error, model adapts |
So there are two layers of retry, and matching them to the error kind is the skill:
- Code-level retry handles transient errors before the model ever sees them — a 429 isn't the model's problem, it's the network's. Back off and try again.
- Agent-level retry handles semantic errors — the model reads
is_errorand fixes the call (new arguments, a different tool, or asks the user).
The classic mistake is using the wrong layer: auto-retrying a semantic error (waiting 4s won't make
"Springfield, ZZ"valid) or bouncing a transient error to the model (now it's reasoning about HTTP 429 instead of the task). Route by kind.
Code-Level Retry: Exponential Backoff + Jitter
For transient errors, the industry-standard mechanism is exponential backoff with jitter — wait longer after each failure (1s, 2s, 4s…), with a little randomness so simultaneous clients don't all retry in lockstep:
import time, random
def with_retry(fn, *, retries=4, base=1.0):
for attempt in range(retries):
try:
return fn()
except (RateLimitError, TimeoutError, ServerError): # TRANSIENT only
if attempt == retries - 1:
raise
delay = base * (2 ** attempt) + random.uniform(0, 0.5) # backoff + JITTER
time.sleep(delay) # ~1s, 2s, 4s …
# The Anthropic SDK already retries 429 / 5xx / 408 / 409 / connection errors
# (max_retries defaults to 2). Wrap YOUR tools the same way — but NEVER auto-retry a
# semantic error: bad arguments won't fix themselves by waiting.Why these specifics:
- Exponential (not fixed) — immediate retries against a struggling service create a thundering herd that keeps it down. Backing off gives it room to recover.
- Jitter — without randomness, all your clients retry at the same instants and synchronize into retry storms. Jitter spreads the load.
- A max-retries cap — give up eventually (then escalate / degrade). Infinite retries are their own outage.
You often get the first layer for free: the Anthropic SDK auto-retries connection errors, 408, 409, 429, and ≥500 with backoff (max_retries defaults to 2). Apply the same pattern to your tools' transient calls — and only the transient ones.
The Idempotency Trap
Here is where retries get dangerous, and it ties straight back to the action tools of L121 (Tools) and "reads parallel, writes serial" from L123 (Parallel & Sequential Tool Calls). A retry is safe only if the operation is idempotent — i.e. running it twice has the same effect as running it once.
Reads are naturally idempotent (get_weather twice is fine). Writes usually aren't: charge_card, send_email, append_to_log, create_order. And the trigger is insidious — a timeout is ambiguous: did the write go through before the connection dropped, or not? If you blind-retry, you might do it twice:
# A timeout is AMBIGUOUS — did the charge go through or not?
charge_card(amount=50) # timeout
charge_card(amount=50) # blind retry → maybe a SECOND charge on top of a silent first 💸💸
# ✅ An idempotency key makes the retry safe — the server deduplicates on it.
key = "order-8821-charge" # stable, derived from the operation
charge_card(amount=50, idempotency_key=key) # timeout
charge_card(amount=50, idempotency_key=key) # server sees the key → charges ONCE ✓The fix is an idempotency key: a stable identifier you attach to the operation so the server can recognize a duplicate and apply it once. (order-8821-charge, derived from the business operation — not a random value per attempt.) Most serious payment, messaging, and write APIs support one for exactly this reason.
The rule that prevents disasters: never blind-retry a non-idempotent write. Either make it idempotent (a key), or don't auto-retry it at all — surface the ambiguous result and let a human or the model decide. A double-charged customer is far worse than a failed request.
See It — The Error Recovery Lab
Below, a tool call fails. Pick the error class and flip the handling between Naive and Robust — and watch the outcomes split apart:

The four scenarios are the whole lesson in miniature:
- 429 rate limit (transient) → backoff recovers; immediate retry hammers the API.
- Bad arguments (semantic) →
is_errorlets the model fix the city; swallowing it produces a confident wrong answer. - Tool crashed (semantic) → returning the traceback lets the model self-heal; an unhandled exception kills the loop.
charge_cardtimeout (non-idempotent) → an idempotency key keeps it to one charge; a blind retry double-charges.
Persistent Failure: Circuit Breakers & Graceful Degradation
Backoff handles a blip. But what if a dependency is down for minutes? Retrying — even with backoff — just burns time, tokens, and API credits while the whole pipeline stalls. The answer is a circuit breaker: after N consecutive failures, stop calling and fail fast.
It's a little state machine: CLOSED (normal) → too many failures → OPEN (stop calling, immediately fall back) → after a cooldown → HALF-OPEN (let one trial through) → success → CLOSED again.
# Persistent failure: stop hammering a dead dependency.
# CLOSED --(failures > threshold)--> OPEN --(cooldown)--> HALF-OPEN --(ok)--> CLOSED
if breaker.is_open("flights_api"):
return fallback() # cached data, an alternative tool, a partial answer,
# or escalate to a human — i.e. degrade GRACEFULLY.When the breaker is open, you degrade gracefully instead of failing hard. Graceful degradation is a philosophy more than a trick: expect failure, contain its blast radius, and preserve core functionality. Concretely:
- Fallback tool — a second provider, or a cheaper/cached path.
- Cached / stale data — "last known" beats nothing.
- Partial answer — return what did work and say what didn't.
- Human escalation — for unrecoverable or high-stakes failures, hand off.
The layered playbook: backoff for transient blips → circuit breaker for persistent outages → fallback for unavailability → human escalation for the unrecoverable.
🧪 Try It Yourself
Reason through these, then use the lab to confirm:
- Predict: in the lab, set Bad arguments → Naive (swallow). What does the model output, and why is that the most dangerous failure mode?
- A tool returns HTTP 429. Which retry layer handles it, and what exactly do you wait between attempts?
- Your agent calls
send_email, gets a timeout, and your harness auto-retries it. What's the risk, and the fix? - The model keeps calling a tool that returns
"Error"over and over, never making progress. Name the root cause and two fixes. flights_apihas been failing for 3 minutes straight and your agent keeps retrying with backoff. What pattern are you missing, and what should happen instead?
→ (1) It hallucinates a confident, plausible answer (e.g. invents the weather) because a swallowed error looks like success — the most dangerous mode since it's a silent wrong answer, not a visible failure. (2) The code/harness layer, with exponential backoff + jitter (~1s, 2s, 4s…) — never bounce a 429 to the model. (3) A timeout is ambiguous, so a blind retry may double-send the email (non-idempotent write). Fix: an idempotency key so the server dedupes — or don't auto-retry the write. (4) The error message is vague ("Error"), so the model retries the same failing call — a retry spiral. Fixes: make the message actionable (error type + how to fix), and cap retries / detect repeated identical failures. (5) A circuit breaker — after N consecutive failures it should trip OPEN, stop calling, and degrade gracefully (fallback/cached/partial/escalate) instead of retrying a dead dependency.
Mental-Model Corrections
- "If a tool fails, the agent should crash." No — catch it and return
is_error. One failed call must not end the run. - "Just return empty/
Noneon failure." That's the worst option — the model thinks it worked and hallucinates. Surface the error. - "
is_error: trueis enough." The flag lets the model know; the message lets it recover. Write an actionable message — it's a prompt. - "Retry everything the same way." Transient → back off in code; semantic → return to the model. Waiting never fixes bad arguments; bouncing a 429 to the model wastes a turn.
- "Retries are always safe." Only for idempotent ops. Blind-retrying a write (charge/send) can double-execute — use an idempotency key.
- "More retries = more reliable." Unbounded retries are an outage. Cap them, add a circuit breaker, then degrade gracefully.
- "Reliability comes from a better prompt." It comes from the harness — validation, retry, backoff, breakers, fallbacks (the ~98%). The prompt is the small part.
Key Takeaways
- Errors are the common case in production (agents retry 15–30% of calls). The harness — not the prompt — is ~98% of reliability.
- Never crash, never swallow: catch failures and return a
tool_resultwithis_error: trueso the model can recover (and every parallel call still needs its own result). - Error messages are prompts: an error type + actionable fix resolves most mistakes on the first retry;
"Error"causes retry loops. - Two failure kinds, two retry layers: transient (429/5xx/timeout) → code-level exponential backoff + jitter (the SDK does this for you); semantic (bad args/404/traceback) → return
is_error, the model adapts. Route by kind. - The idempotency trap: a timeout is ambiguous — never blind-retry a non-idempotent write (double-charge!). Use an idempotency key.
- Persistent failure: a circuit breaker (CLOSED→OPEN→HALF-OPEN) stops hammering a dead dependency; then degrade gracefully — fallback, cache, partial answer, or human escalation.
- Avoid the retry spiral: cap retries, detect repeated identical failures, and never mask a real bug behind a retry.
- Next — L126: Hands-On — build a real agent wiring together web search + a database + a calculator, applying everything from L121–L125 (Tools → Handling Tool Errors & Retries).