C4 · lesson 2 · 10 min read
Tool errors and retries
Report tool failures with is_error, write error messages Claude can act on, and decide what your loop retries and what it stops on.
Հայերեն
Tools fail. Clusters time out, alert IDs are wrong, tokens expire. What matters
is whether Claude knows a call failed and has enough information to do
something sensible next. The Messages API has one flag for this: is_error.
The tool_result block
After a tool_use, you reply with a user message containing a tool_result:
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01A09q90qw90lq917835lq9",
"content": "Alert ALERT-999 not found. Valid IDs look like ALERT-1234. Call alerts_list to see open alerts.",
"is_error": true
}
]
}
tool_use_idmust match theidof thetool_useblock.contentcan be a string or a list oftext,image,documentorsearch_resultblocks. It can also be left out entirely.is_error: truetells Claude the call failed. Leave it out on success.
Three formatting rules cause most 400 errors:
- The results must come in the message immediately after the assistant
message with the
tool_useblocks. Nothing can go in between. - In that user message, the
tool_resultblocks come first. Any text goes after them. - When Claude made several calls, return all results in one user message.
Two kinds of failure
| Failure | What to send | What Claude does |
|---|---|---|
| The tool ran and failed (API down, not found) | is_error: true and a message that explains it |
Tells the user, or tries another approach |
| The call was invalid (missing or bad argument) | is_error: true, for example Missing required 'namespace' parameter |
Retries with corrections, typically 2–3 times, then apologises |
If you see many invalid calls, fix the cause. Improve the tool and parameter
descriptions, or set strict: true on the tool so its inputs always match the
schema.
Write errors Claude can act on
"failed" or "Error 500" leaves Claude guessing. A useful error message says
what went wrong and what to try next:
Rate limit exceeded. Retry after 60 seconds.Namespace "payment" does not exist. Did you mean "payments"?Permission denied: this tool is read-only and cannot scale deployments. Ask the user to run the change through the deploy pipeline.
Keep the message short and free of secrets. Never return stack traces, connection strings or tokens; tool output goes into the model’s context and often into logs.
Retries: whose job is it?
Split retries into two layers:
- Transport retries belong in your code. A 503 from the monitoring API, or a connection reset, can be retried with backoff inside the tool before Claude ever sees it. Retry only idempotent reads automatically.
- Semantic retries belong to Claude. A wrong ID or a bad date format needs a
different call, so return
is_errorwith guidance and let Claude fix it. - Your loop needs a hard stop. Cap the number of turns and the time spent so that a stubborn failure ends the run instead of using up your budget.
# tool_errors.py - needs ANTHROPIC_API_KEY in the environment
import anthropic
client = anthropic.Anthropic()
MAX_TURNS = 6
ALERTS = {"ALERT-1234": "payments p99 latency > 2s for 10m (firing)"}
tools = [{
"name": "alerts_get",
"description": "Returns status and summary of one monitoring alert by ID (format ALERT-<digits>). "
"Read-only. Returns an error if the ID does not exist.",
"input_schema": {"type": "object",
"properties": {"alert_id": {"type": "string"}},
"required": ["alert_id"]},
}]
def alerts_get(alert_id: str) -> str:
if alert_id not in ALERTS:
raise LookupError(f"Alert {alert_id} not found. Known open alerts: {', '.join(ALERTS)}.")
return ALERTS[alert_id]
def run_tool(block) -> dict:
result = {"type": "tool_result", "tool_use_id": block.id}
try:
if block.name != "alerts_get":
raise LookupError(f"Unknown tool {block.name}.")
result["content"] = alerts_get(**block.input)
except TimeoutError:
result |= {"content": "Alert API timed out. Retry once; if it fails again, tell the user.", "is_error": True}
except (LookupError, TypeError) as exc:
result |= {"content": str(exc), "is_error": True}
return result
messages = [{"role": "user", "content": "What is ALERT-999 about?"}]
for turn in range(MAX_TURNS):
resp = client.messages.create(model="claude-sonnet-5-5", max_tokens=1024, tools=tools, messages=messages)
messages.append({"role": "assistant", "content": resp.content})
if resp.stop_reason != "tool_use":
print("".join(b.text for b in resp.content if b.type == "text"))
break
messages.append({"role": "user", "content": [run_tool(b) for b in resp.content if b.type == "tool_use"]})
else:
raise SystemExit(f"Stopped after {MAX_TURNS} turns without a final answer.")
The same idea in MCP
MCP servers have the same split. A tool execution error is a normal result
with isError: true (camelCase in MCP), and clients should pass it to the model
so it can correct itself. A protocol error, such as an unknown tool or a
malformed request, is a JSON-RPC error, and the model may never see it. In the
Python MCP SDK you raise ToolError to get the first kind.
Recap
- Report failures with a
tool_resultthat hasis_error: trueand a short, actionable message: what went wrong and what to do next. - Results come right after the
tool_useturn,tool_resultblocks first, and all of them in one user message. - Claude typically retries invalid calls 2–3 times. Fix repeated failures with
better descriptions or
strict: true. - Retry transport errors in code, let Claude handle semantic errors, and always cap your loop.
$ ./try-it.sh
Try it: an alert lookup that fails well
- Export
ANTHROPIC_API_KEY, save the script from this lesson astool_errors.py, and run it. Watch Claude callalerts_getwithALERT-999, read the error, and recover. - Change the not-found branch to return
"failed"withoutis_error. Run it again and note how much less useful the final answer is. - Make
alerts_getraiseTimeoutErrorand check that the loop reports it with a retry hint instead of crashing. - Lower
MAX_TURNSto 2 and confirm that the loop stops with a clear message instead of running forever.
Check yourself
4 questions. Original practice questions, not taken from the real exam.