Handle Tool Errors

Su Suriya Ravichandran Updated 10 Nov 2026
10 min read ·Lesson 90 of 200

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

KindExampleWhose problemWhat Claude can do
Unknown toolA name that is not in your registryModelPick a valid tool (list them in the message)
Invalid inputMissing topic, a wrong value for levelModelCorrect the input and retry
Not foundNo course with slug rust-101RequestSearch first, or tell the user
Not allowedA student trying to read another student's dataRequestExplain that it cannot do that
Temporary failureA service timeout, a rate limit on an external APIEnvironmentRetry later, or answer without the tool
Unexpected exceptionA bug in your tool codeDeveloperAnswer without the tool
Incomplete callThe response hit max_tokens while writing tool inputRequest sizeNothing: 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:

WeakStrong
"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:

  1. The step limit of the agent loop (Lesson 88) caps the total number of rounds.
  2. 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

  1. get_course_fee() simulates a service timeout and converts it into a ToolFailure with a helpful message. from None hides the original exception chain, because the message already says everything Claude needs.
  2. FailureTracker counts consecutive failures per tool. A success resets the count to 0. After MAX_FAILURES_PER_TOOL failures 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.
  3. 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.
  4. Truncated tool calls: if a response ends with max_tokens and contains a tool_use block, its input may be incomplete. The loop doubles max_tokens and retries the same request, without appending the truncated response, so the incomplete call never runs and never enters the history.
  5. Every call still gets a result, success or error, all in one user message (Lesson 87).
  6. 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_error tool 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

  1. Change get_course_fee() to succeed, returning {"fee_inr": 4999, "discount": "10% for students"}, and run the agent again.
  2. Set MAX_FAILURES_PER_TOOL = 1. How does the run change?
  3. Add a ToolFailure to search_courses when the topic is shorter than 2 characters, with a message that asks for a more specific topic.
  4. Add logging to FailureTracker.run() so that every error outcome is logged with the tool name and its message.
Show answers for steps 2 and 4

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.

0 Comments

Reviewed before they appear

No comments yet.

Anthropic SDK Using Python
Anthropic SDK Intro Claude API Intro SDK Requirements Install Anthropic SDK Upgrade the SDK Create an API Key Set API Keys Use Environment Variables Import Anthropic Create a Client Send Your First Message Choose a Claude Model Set Max Tokens Read a Response Print Response Text Understand Message Blocks Use System Prompts Send User Messages Set Temperature Handle Stop Reasons Messages API Intro Create Messages Use Message Roles Send Conversation History Continue a Conversation Build a Chat Loop Reset Chat History Use Multiple Turns Inspect Message IDs Read Usage Data Write Clear Prompts Set a System Prompt Add Context to Prompts Use Prompt Templates Add Examples to Prompts Request JSON Output Use XML Tags Control Response Length Improve Prompt Quality Avoid Prompt Injection Read Text Blocks Read Content Blocks Check Stop Reasons Count Input Tokens Count Output Tokens Count Tokens Before Sending Limit Output Tokens Use Stop Sequences Read Request IDs Parse API Responses Streaming Intro Stream Text Output Use Stream Events Use Stream Helpers Get Final Messages Stream Async Responses Handle Stream Errors Build a Live Chat Stream Long Responses Close Streams Safely Build a Chatbot Keep Chat History Manage Chat Sessions Add Chat Memory Build a CLI Chatbot Build a Web Chatbot Use Flask with Claude Use FastAPI with Claude Return JSON from an API Add Chat Error Messages Async SDK Intro Create an Async Client Send Async Messages Use asyncio Run Parallel Requests Stream Async Text Handle Async Errors Set Async Timeouts Close Async Clients Choose Sync or Async Tool Use Intro Define a Tool Create Tool Schemas Send Tools to Claude Read Tool Calls Run Python Tools Return Tool Results Continue After Tool Use Control Tool Choice Handle Tool Errors Use the beta_tool Decorator Create a Tool Runner Add Tool Docstrings Validate Tool Inputs Use Multiple Tools Limit Tool Calls Log Tool Activity Build a Weather Tool Build a Search Tool Build a Calculator Tool Structured Output Intro Request JSON Data Validate JSON Output Use Pydantic Models Handle Invalid JSON Extract Fields from Text Generate Typed Data Use Image URLs Send Base64 Images Read PDFs with Claude Use Output Schemas Build Data Extraction Test Structured Responses Send Image Inputs Upload Files Use File IDs Process Text Files Summarize Documents Extract Table Data Handle File Errors Read Cache Usage Reduce Repeated Input Costs Debug Cache Misses Prompt Caching Intro Enable Prompt Caching Cache System Prompts Cache Long Context Test Cache Performance Use Cache Breakpoints Cache Best Practices Message Batches Intro Create a Message Batch Set Batch IDs Check Batch Status List Message Batches Get Batch Results Process Batch Outputs Handle Failed Batch Items Use Batches for Bulk Tasks Compare Batches and Requests Handle API Errors Handle Rate Limits Retry Failed Requests Set Request Timeouts Handle Network Errors Use Exponential Backoff Build Reliable API Calls Use Extended Thinking Log API Errors Track Request IDs Paginate API Results Set Thinking Budgets Read Thinking Blocks Use Tool Search Use Web Search Tools Use Computer Tools Use Citations Use Token Counting Read Raw Responses Set Custom Headers Use Claude on AWS Bedrock Use Claude on Vertex AI Use Claude on Microsoft Foundry Configure Cloud Credentials Choose a Cloud Provider Compare API Integrations Configure Provider Clients Handle Cloud Errors Use Cloud SDK Extras Deploy Cloud Apps Protect API Keys Use a .env File Ignore Secrets in Git Validate User Input Reduce Prompt Injection Limit Tool Permissions Avoid Unsafe Tool Calls Sanitize Tool Results Set Usage Limits Review Security Logs Test SDK Functions Mock API Responses Test Error Handling Test Streaming Code Measure Token Usage Track API Costs Configure Logging Deploy with Docker Deploy a FastAPI App Monitor Production Apps Build a Claude Chatbot Build a Text Summarizer Build a Code Explainer Build a JSON Extractor Build a PDF Assistant Build a Streaming Chat App Build a Tool-Calling Agent Build a Batch Processor Build a Research Assistant Build a Claude API Dashboard
Ask about this post
AI Ask about this post

Ask questions about Handle Tool Errors and get answers drawn from it.

Signed-in readers only.