When a tool call goes wrong

A failed tool result is an input to the next step, not the end of the request - so decide what the next step should be.

Before anything else, the seam. The Prompt Engineering course has a lesson called Retries and fallbacks. That one is about a single call: it failed, do you send it again, and what do you return when you stop trying. Read it - the ladder in it is still the right ladder.

This lesson starts where that one ends. Here the request is not over. There is a plan in flight, three other tools on the table, and work from the previous nine steps you would rather not throw away. The question is not "retry or give up". It is what should the next step be, given what just failed - and that is a question only a loop gets to ask.

A tool error is a message, not an exception

When your tool raises, nothing raises in the model's world. The model is not running your code. It asked for a call, and the only thing it will ever learn about the outcome is the text you choose to put in the tool_result block. You are writing that text. It is a prompt.

Two ways engineers get it wrong, and they fail in opposite directions.

Sending nothing. You catch the exception, return an empty result, and the run limps on. That is the opener. An empty result is indistinguishable from a genuine empty set, and a model that reads "no data" on a step it needed data for will do the most human thing available: carry on with an assumption. Set is_error: true and say what happened.

Sending everything. You return a forty-line stack trace. The model reads a Python traceback it cannot do anything with, spends a step, and usually tries the identical call again. Anthropic's own tool-use docs put it plainly: write instructive errors, not generic ones - Rate limit exceeded. Retry after 60 seconds. rather than failed. The error text should name the problem and the next move.

Where the failure came from decides who can fix it, and that is the classification worth making:

OriginWhat it looks likeWho can fix it
The model built bad argumentsSchema violation, missing field, an enum value that does not existThe model, if you tell it exactly what was wrong
The world said no404, permission denied, an empty but legitimate resultNobody, by retrying. The plan has to change
The infrastructure failedTimeout, 5xx, connection reset, 429Time, plus backoff - and none of it needs the model
Your harness refusedBudget spent, unknown tool, policy blockYou. Say so in the error so the model stops trying

Note the third row. Those failures carry no information a language model can use. Handle them under the tool, where a retry costs milliseconds instead of a full model call on a growing transcript.

Four moves, and how to pick one

MoveUse it whenDo it whereCost of choosing it wrongly
Retry the same callTimeout, 5xx, 429In the harness, invisiblyYou retry a 400 forever and pay for it
Rewrite the argumentsSchema or validation errorReturn the error, let the model repairYou paper over a bad tool schema
Try a different routeThe world said noThe model's next stepYou give up on a task another tool could finish
Stop and say soThe failure is on the critical path and the routes are goneThe harnessYou return an answer built on a hole

Retry the same call. Backoff with jitter, two or three attempts, then surface it. The model should never see a transient failure that resolved itself.

Rewrite the arguments. Return the validator's own message, verbatim - the field name and the constraint are exactly what the model needs. Cap it. Anthropic's docs say Claude will retry an invalid tool call two or three times on its own before apologising to the user; your cap should sit in the same neighbourhood, because past that point the model is not the thing that is wrong. Go and read your tool's description. This class of failure mostly disappears if you turn on strict tool use, which makes the API guarantee the arguments match your schema.

Try a different route. This is the move the single-call lesson cannot make. get_user(id) returns 404, and search_users(email) might not. A step failed; the plan still has options. For the model to take one, your error has to say the door is closed - No user with that id. Do not retry; try search_users. - or it will knock again.

Stop and say so. Partial result, the step that failed, the error. Planning a run, and knowing when to stop's budget_exhausted outcome and this one are siblings: both are honest endings, and both beat an invented answer.

The fifth move is the one in the opener, and it is never correct: continue as though the call had worked.

An empty tool result is not the absence of an answer. It is an invitation to invent one.

Making it hold up over a long run

Budget failures per tool, not just per run. Count how many times each tool has failed this run. At three, take it off the table - and tell the model you have, in the error text, so it stops planning around a tool that is gone. That is a circuit breaker scoped to a single run, and it is the difference between a bad tool costing you three steps and a bad tool costing you the whole budget.

Leave the failed call in the transcript. It is tempting to delete a failed call to keep the context clean. Do not. The record of "I tried that and it did not work" is the only thing stopping the model trying it again, and it is cheap. When you start clearing old tool results - the next lesson - failures are the ones to keep.

Guard side effects with an idempotency key. A tool that writes, sends, or charges may have succeeded before it timed out. Give each logical operation a key, check the key before acting, and a retry becomes safe. The model call is always safe to repeat; what the tool does with it usually is not.

import logging
import random
import time

from pydantic import ValidationError

log = logging.getLogger(__name__)
TRANSIENT = (TimeoutError, ConnectionError)
MAX_TRANSIENT_ATTEMPTS = 3
MAX_FAILURES_PER_TOOL = 3

def call_tool(name, args, tools, failures):
    """Returns (content, is_error) for a tool_result block."""
    if failures.get(name, 0) >= MAX_FAILURES_PER_TOOL:
        return (f"{name} has failed {MAX_FAILURES_PER_TOOL} times and is unavailable "
                "for the rest of this run. Use another tool, or finish with what "
                "you have."), True

    try:
        parsed = tools[name].schema(**args)          # your validator
    except ValidationError as err:
        failures[name] = failures.get(name, 0) + 1
        return f"Invalid arguments: {err}. Correct them and call {name} again.", True

    for attempt in range(MAX_TRANSIENT_ATTEMPTS):
        try:
            return tools[name].run(parsed), False
        except TRANSIENT as err:
            log.warning("%s transient failure on attempt %s: %s", name, attempt, err)
            if attempt < MAX_TRANSIENT_ATTEMPTS - 1:
                time.sleep(2 ** attempt + random.random())   # backoff, below the loop
        except PermissionError as err:
            failures[name] = failures.get(name, 0) + 1
            return f"Permission denied: {err}. Do not retry {name}; try another route.", True
        except LookupError as err:
            return f"Not found: {err}. This is a real empty result, not a failure.", False

    failures[name] = failures.get(name, 0) + 1
    return f"{name} is unreachable. Try a different tool, or stop and report this.", True

Read the two False returns. One is a success. The other is a genuine "not found" that is not an error at all - and telling those apart in your code, rather than making the model guess from an empty string, is most of this lesson in one branch.

Every error string in there names the problem and the next move. None of them is a stack trace, and none of them is blank.

WHAT YOU TAKE AWAY

  1. Send the tool's error back as a `tool_result` with `is_error` set, never as an empty result.
  2. Write error text the model can act on: what went wrong and what to try next.
  3. Retry infrastructure failures below the loop, so a dropped socket costs no model call.
  4. Cap failures per tool per run, then disable that tool and say so.
  5. Never let a run continue as though a failed call had worked.

RECALL NO SCROLLING BACK

00 / 05 answered

  1. QUESTION 01

    Your tool raises and you return an empty `tool_result` with no error flag. What does the model most likely do?

  2. QUESTION 02

    A tool call fails with a 503. Where does that retry belong?

  3. QUESTION 03

    The model has now sent invalid arguments to the same tool three times. The right conclusion is...

  4. QUESTION 04

    How is this different from the Prompt Engineering lesson 'Retries and fallbacks'?

  5. QUESTION 05

    A `send_email` tool times out. The safe next move is...