Skip to content

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.

Ընտրեք տեքստը՝ հայերեն թարգմանությունը տեսնելու համար։Highlight text to see Armenian. Machine-assisted translation; English is the source of truth.

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_id must match the id of the tool_use block.
  • content can be a string or a list of text, image, document or search_result blocks. It can also be left out entirely.
  • is_error: true tells Claude the call failed. Leave it out on success.

Three formatting rules cause most 400 errors:

  1. The results must come in the message immediately after the assistant message with the tool_use blocks. Nothing can go in between.
  2. In that user message, the tool_result blocks come first. Any text goes after them.
  3. 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_error with 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_result that has is_error: true and a short, actionable message: what went wrong and what to do next.
  • Results come right after the tool_use turn, tool_result blocks 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

  1. Export ANTHROPIC_API_KEY, save the script from this lesson as tool_errors.py, and run it. Watch Claude call alerts_get with ALERT-999, read the error, and recover.
  2. Change the not-found branch to return "failed" without is_error. Run it again and note how much less useful the final answer is.
  3. Make alerts_get raise TimeoutError and check that the loop reports it with a retry hint instead of crashing.
  4. Lower MAX_TURNS to 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.

  1. Your alerts_list tool calls a monitoring API that just timed out. The current code catches the exception and returns the string "no open alerts".

    1. What should the tool_result contain instead?

  2. After Claude returns two tool_use blocks, your loop sends one user message with a text block "Here are the results:" followed by the two tool_result blocks. The API rejects the request with a 400.

    2. What is the fix?

  3. You're hardening an agent loop that calls a read-only metrics API and a non-idempotent create_ticket API. Some calls fail with transient 503s, and once a stuck loop ran for 40 turns.

    3. Which two responsibilities belong in your application code? (Choose two.)

    Choose 2.

  4. Logs show Claude often calls rollout_status without the required namespace argument. Your loop returns is_error with "Missing required 'namespace' parameter". Claude usually corrects itself, but each failure costs extra turns.

    4. What is the best long-term fix?

Answer every question to check.