Handle Tool Errors
Errors Are Part of Tool Use
Tools fail. Claude may call a tool with an invalid input, an external service may be down, a requested record may not exist, or your own code may contain a bug. In tool use, failures are normal events, and handling them well is what makes an agent reliable.
The key idea: a tool error is information for Claude. When you report a failure with is_error: true and a clear explanation (Lesson 87), Claude can adapt: correct its input, try another tool, or explain the limitation to the user. Crashing, or silently returning nothing, takes that ability away.
Kinds of Tool Errors
| Kind | Example | Whose problem | What Claude can do |
|---|---|---|---|
| Unknown tool | A name that is not in your registry | Model | Pick a valid tool (list them in the message) |
| Invalid input | Missing topic, a wrong value for level | Model | Correct the input and retry |
| Not found | No course with slug rust-101 | Request | Search first, or tell the user |
| Not allowed | A student trying to read another student's data | Request | Explain that it cannot do that |
| Temporary failure | A service timeout, a rate limit on an external API | Environment | Retry later, or answer without the tool |
| Unexpected exception | A bug in your tool code | Developer | Answer without the tool |
| Incomplete call | The response hit max_tokens while writing tool input | Request size | Nothing: your code should retry with a higher limit |
Each kind calls for a different message, and some call for action in your code as well.
Writing Good Error Messages for Claude
A good error message tells Claude what went wrong and what to do next:
| Weak | Strong |
|---|---|
"Error" | "No course with slug 'rust-101'. Use search_courses to find valid slugs." |
"Invalid input" | "Invalid input for search_courses: topic: Field required" |
"KeyError: 'fee'" | "The payments service is not responding right now. Try again later, or answer without the fee." |
| A full stack trace | "get_course_fee failed unexpectedly. Answer without this tool." (stack trace logged for developers) |
Rules:
- Be specific and actionable. Name the problem, and suggest a next step.
- Never include internal details: stack traces, SQL, file paths, or secrets. Tool results become part of the conversation and may be repeated to users. Log the details for developers instead (Lesson 86).
- Keep them short. Errors are input tokens too.
Expected vs. Unexpected Failures
tools_lib.py (Lesson 86) separates two kinds of failures:
ToolFailure: an expected problem, with a message written for Claude. Raise it for not-found, not-allowed, and known external-service failures.- Any other exception: an unexpected bug, logged with its traceback, and reported to Claude with a generic message.
Converting known external errors into ToolFailure gives Claude useful messages instead of generic ones:
def get_course_fee(args: FeeInput, ctx: ToolContext) -> dict:
try:
return payments_api.fee(args.course_slug, timeout=5) # Your real service call
except TimeoutError:
raise ToolFailure("The payments service is not responding right now. "
"Try again later, or answer without the fee.") from None
Preventing Endless Retries
Claude often retries a failed tool, which is helpful for invalid inputs, because the next attempt is usually corrected. For a service that is down, however, repeated retries only waste steps and tokens.
Two safeguards keep this under control:
- The step limit of the agent loop (Lesson 88) caps the total number of rounds.
- A per-tool failure limit: after a tool has failed several times in a row in one conversation, respond to further calls with an error saying the tool is unavailable and should not be called again.
The tool stays in the tool list. Removing it mid-conversation would make the history inconsistent (Lesson 84), so the limit is enforced through the error message instead.
Incomplete Tool Calls (max_tokens)
If a response stops with stop_reason == "max_tokens" while Claude was writing a tool call, the call's input may be incomplete. Never run it. Instead, retry the same request with a larger max_tokens (Lesson 85). Do not append the truncated response to the conversation.
Practical Example: An Agent Loop with Error Handling
This agent extends the loop from Lesson 88 with:
- a flaky tool,
get_course_fee, that simulates an unavailable payments service; - a failure tracker that disables a tool for the rest of the conversation after repeated failures;
- retrying truncated tool calls with a larger
max_tokens; - a system prompt that tells Claude how to behave when tools fail.
"""tool_errors.py: an agent loop that handles tool errors and stops repeated failures."""
from collections import Counter
from anthropic import Anthropic
from anthropic.types import Message
from pydantic import BaseModel, ConfigDict, Field
from demo_tools import REGISTRY as BASE_REGISTRY
from tools_lib import Tool, ToolContext, ToolFailure, ToolOutcome, run_tool
client = Anthropic()
MODEL = "claude-opus-5-5"
MAX_STEPS = 8
MAX_FAILURES_PER_TOOL = 2
SYSTEM = (
"You are the GOCOURSE study assistant. Use the tools for course, enrollment, weather, and fee "
"questions. If a tool fails, you may retry once if a retry could help. If it keeps failing, "
"tell the student which information you could not get, instead of guessing it."
)
class FeeInput(BaseModel):
model_config = ConfigDict(extra="forbid")
course_slug: str = Field(description="Course slug from search_courses, e.g. 'django-101'")
def get_course_fee(args: FeeInput, ctx: ToolContext) -> dict:
try:
raise TimeoutError("payments service timeout") # Simulates an outage
except TimeoutError:
raise ToolFailure("The payments service is not responding right now. "
"Try again later, or answer without the fee.") from None
REGISTRY = {
**BASE_REGISTRY,
"get_course_fee": Tool(
"get_course_fee",
"Returns the current fee for one course in Indian rupees, identified by its slug. "
"Use it when a student asks what a course costs. Get the slug from search_courses first. "
"Returns the fee and any active discount as JSON.",
FeeInput, get_course_fee,
),
}
TOOL_DEFINITIONS = [tool.definition() for tool in REGISTRY.values()]
class FailureTracker:
"""Stops repeated calls to a tool that keeps failing within one conversation."""
def __init__(self, limit: int = MAX_FAILURES_PER_TOOL):
self.limit = limit
self.consecutive_failures: Counter[str] = Counter()
def run(self, name: str, raw_input: dict, ctx: ToolContext) -> ToolOutcome:
if self.consecutive_failures[name] >= self.limit:
return ToolOutcome(
f"{name} is unavailable for the rest of this conversation. "
"Do not call it again; answer without it.", True)
outcome = run_tool(REGISTRY, name, raw_input, ctx)
self.consecutive_failures[name] = self.consecutive_failures[name] + 1 if outcome.is_error else 0
return outcome
def text_of(response: Message) -> str:
return "".join(b.text for b in response.content if b.type == "text").strip()
def run_agent(question: str, ctx: ToolContext) -> str:
tracker = FailureTracker()
messages = [{"role": "user", "content": question}]
max_tokens = 2048
for step in range(1, MAX_STEPS + 1):
response = client.messages.create(model=MODEL, max_tokens=max_tokens, system=SYSTEM,
tools=TOOL_DEFINITIONS, messages=messages)
if response.stop_reason == "max_tokens" and any(b.type == "tool_use" for b in response.content):
max_tokens *= 2
print(f" [step {step}] tool call cut off; retrying with max_tokens={max_tokens}")
continue # Do not append the truncated response
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
prefix = "" if response.stop_reason == "end_turn" else f"[Stopped: {response.stop_reason}] "
return prefix + text_of(response)
results = []
for block in response.content:
if block.type != "tool_use":
continue
outcome = tracker.run(block.name, dict(block.input or {}), ctx)
status = "ERROR" if outcome.is_error else "ok"
print(f" [step {step}] {block.name}({block.input}) -> {status}: {outcome.content}")
result = {"type": "tool_result", "tool_use_id": block.id, "content": outcome.content}
if outcome.is_error:
result["is_error"] = True
results.append(result)
messages.append({"role": "user", "content": results})
return "[Stopped: the task needed too many tool steps.]"
if __name__ == "__main__":
question = "What beginner Django course do you have, and how much does it cost?"
print(f"Q: {question}")
print(f"A: {run_agent(question, ToolContext(user_id='meena'))}")
Example output (Claude's exact steps and wording vary):
Q: What beginner Django course do you have, and how much does it cost?
[step 1] search_courses({'topic': 'Django', 'level': 'beginner'}) -> ok: [{"slug": "django-101", "title": "Django for Beginners", "level": "beginner", "weeks": 6}]
[step 2] get_course_fee({'course_slug': 'django-101'}) -> ERROR: The payments service is not responding right now. Try again later, or answer without the fee.
[step 3] get_course_fee({'course_slug': 'django-101'}) -> ERROR: The payments service is not responding right now. Try again later, or answer without the fee.
A: We have Django for Beginners, a 6-week beginner course. I couldn't get its current fee because the payments service isn't responding right now. Please check the course page, or ask me again in a little while.
How It Works
get_course_fee()simulates a service timeout and converts it into aToolFailurewith a helpful message.from Nonehides the original exception chain, because the message already says everything Claude needs.FailureTrackercounts consecutive failures per tool. A success resets the count to 0. AfterMAX_FAILURES_PER_TOOLfailures in a row, further calls are not executed at all: they immediately return an error saying the tool is unavailable and must not be called again. The tool remains in the tool list, so the conversation stays consistent.- In the sample run, Claude found the course, tried the fee tool, retried once (as the system prompt allows), and then answered honestly without the fee. The tracker would have stopped a third attempt.
- Truncated tool calls: if a response ends with
max_tokensand contains atool_useblock, its input may be incomplete. The loop doublesmax_tokensand retries the same request, without appending the truncated response, so the incomplete call never runs and never enters the history. - Every call still gets a result, success or error, all in one user message (Lesson 87).
- The system prompt sets the recovery policy: retry once if useful, then explain what could not be checked instead of inventing it. This keeps answers honest when tools fail.
Common Mistakes
Raising Exceptions Out of the Loop
What happens: the request crashes, and the conversation is left with an unanswered tool call.
Fix: convert every tool failure into an is_error result.
Vague or Internal Error Messages
What happens: Claude cannot recover from "Error", and stack traces leak internal details.
Fix: write specific, actionable, safe messages; log the details separately.
Letting a Broken Tool Be Retried Forever
What happens: steps and tokens are wasted until the step limit is reached.
Fix: track failures per tool, and tell Claude when to stop calling it.
Running Truncated Tool Calls
What happens: a tool runs with incomplete input.
Fix: retry with a higher max_tokens when a response with a tool call stops at max_tokens.
Answers That Hide Failures
What happens: Claude fills in missing information with guesses, such as a made-up fee.
Fix: instruct Claude to say what it could not check.
Best Practices
- Report every failure as an
is_errortool result. - Write errors for Claude: what went wrong, and what to do next.
- Use expected-failure exceptions for known problems, and generic messages for bugs.
- Log details for developers, with context such as tool name and request ID.
- Limit retries per tool and steps per conversation.
- Retry truncated tool calls with a larger
max_tokens. - Tell Claude how to behave when tools fail, so answers stay honest.
Exercise
- Change
get_course_fee()to succeed, returning{"fee_inr": 4999, "discount": "10% for students"}, and run the agent again. - Set
MAX_FAILURES_PER_TOOL = 1. How does the run change? - Add a
ToolFailuretosearch_courseswhen the topic is shorter than 2 characters, with a message that asks for a more specific topic. - Add logging to
FailureTracker.run()so that every error outcome is logged with the tool name and its message.
Step 2: after the first failure, the tool is already at the limit. If Claude tries again, it receives the "unavailable … do not call it again" message without the tool running, and then answers without the fee. The conversation ends sooner, at the cost of not retrying a temporary failure. Choose the limit according to how often your tools recover on a second attempt.
Step 4:
import logging
logger = logging.getLogger("tools")
def run(self, name, raw_input, ctx):
...
outcome = run_tool(REGISTRY, name, raw_input, ctx)
if outcome.is_error:
logger.warning("tool error: %s input=%s message=%s", name, raw_input, outcome.content)
...
Logging the input together with the message makes it possible to see whether errors come from the model's inputs (fix descriptions or schemas) or from your services (fix the service or its error handling). Avoid logging inputs that may contain sensitive personal data.