Claude API Tool Use Error Handling: A Practical Guide
When Claude calls a tool and something goes wrong, the failure can happen in at least four different places: Claude sends malformed input, your tool execution throws, the tool times out, or you format the tool_result incorrectly and confuse the next turn. Each of these needs a different handling strategy, and treating them all the same way is why most tool-use integrations feel flaky.
The short answer: validate tool inputs before executing, always return a tool_result block even on failure (using is_error: true), never let an unhandled exception break the conversation loop, and build retry logic around specific failure types rather than blanket try/catch. The rest of this article walks through each failure mode and how to handle it correctly.
Why tool use errors are different from normal API errors
A normal Claude API error — rate limit, invalid request, server error — happens at the HTTP layer and you handle it with standard retry/backoff logic. Tool use errors are different because they happen inside a multi-turn conversation. Claude asks for a tool call, you execute it, and you have to send something back in the next message or the conversation breaks. There's no way to just "retry the request" the way you would with a 429 — you need to tell Claude what happened and let it decide what to do next.
This means tool error handling has two layers:
- Transport-layer errors — the API call itself fails (network, rate limit, auth).
- Tool execution errors — the API call succeeds, Claude requests a tool, and the tool itself fails.
Most bugs in production integrations come from conflating these two.
Handling malformed or unexpected tool inputs
Claude generates tool inputs based on your JSON schema, but schemas don't guarantee semantic correctness. A get_weather tool might get a city field that's an empty string, or a days parameter that's negative. Validate before you execute:
function validateToolInput(toolName, input) {
if (toolName === "get_weather") {
if (!input.city || typeof input.city !== "string") {
throw new ToolInputError("city must be a non-empty string");
}
if (input.days && (input.days < 1 || input.days > 14)) {
throw new ToolInputError("days must be between 1 and 14");
}
}
return true;
}
When validation fails, don't crash the loop — return an error tool_result so Claude can correct itself:
{
type: "tool_result",
tool_use_id: toolUseBlock.id,
content: "Invalid input: days must be between 1 and 14",
is_error: true
}
Claude will typically retry with corrected input on the next turn. This self-correction is one of the most useful properties of tool use, but only if you actually surface the error instead of swallowing it.
Handling execution failures
Your tool code will fail for reasons that have nothing to do with Claude's input: a downstream API is down, a database query times out, a file doesn't exist. Wrap every tool execution in a try/catch and always produce a tool_result, even in the failure case:
async function executeTool(toolUseBlock) {
try {
validateToolInput(toolUseBlock.name, toolUseBlock.input);
const result = await runTool(toolUseBlock.name, toolUseBlock.input);
return {
type: "tool_result",
tool_use_id: toolUseBlock.id,
content: JSON.stringify(result)
};
} catch (err) {
return {
type: "tool_result",
tool_use_id: toolUseBlock.id,
content: `Tool execution failed: ${err.message}`,
is_error: true
};
}
}
The key rule: never let a tool exception propagate out of your conversation loop unhandled. If it does, you lose the tool_use_id reference and the conversation is stuck — Claude is waiting for a result that will never arrive, and the next request will likely fail validation because a tool call has no matching result.
Timeouts and slow tools
Some tools call external APIs that are slow or unreliable. Set an explicit timeout per tool and treat a timeout as its own error type rather than letting the whole request hang:
async function runWithTimeout(fn, ms = 8000) {
return Promise.race([
fn(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Tool timed out")), ms)
)
]);
}
Report timeouts back to Claude as is_error: true results with a clear message. Claude can often route around a failed tool — falling back to a different approach or telling the user the data isn't available — but only if it knows the call failed rather than silently getting nothing back.
Retry strategy: know what to retry
Not every failure should trigger a retry. A malformed input error should go back to Claude, not be retried by your code — Claude needs to generate a different input, not run the exact same one again. But a transient network error calling an external API inside your tool should be retried by your code before you ever report failure to Claude:
- Tool input validation failure → return
is_errorresult, let Claude retry. - Transient execution error (network blip, 503 from downstream API) → retry inside your tool function 1–2 times with backoff, then report failure if still failing.
- Permanent execution error (auth failure, resource not found) → don't retry, report immediately with a specific message.
- API-level errors (429, 529 from Claude itself) → standard exponential backoff at the HTTP layer, unrelated to tool logic.
Mixing these up — for example, retrying a malformed-input case at the code level instead of letting Claude regenerate the input — just wastes calls and produces the same bad result repeatedly.
Logging tool errors for debugging
Tool errors are easy to lose track of because they live inside conversation state rather than showing up as HTTP failures. Log every is_error tool result with the tool name, input, and error message, separate from your general API error logs. This is usually the fastest way to spot a systemic issue — like a tool schema that consistently produces bad inputs from Claude, which is a prompt/schema problem, not a runtime bug.
If you're running tool-using agents through SubToAPI, each request carries usage metadata in the dashboard, which helps you correlate spikes in token usage or latency with tool call patterns — useful when a tool is silently retrying more than expected. Tool use itself works the same way as calling Claude directly; see /docs/tools for the request format and /docs/messages for how tool results fit into the message structure.
Putting it together
A solid tool-use error handling loop looks like this: validate input → execute with a timeout → catch and classify the error → retry transient failures internally → return a tool_result (success or is_error) for every tool call, always. Skipping any of these steps is what turns an occasional tool failure into a broken conversation.
questions
Do I need to send a tool_result even when the tool fails? Yes. Every tool_use block in Claude's response needs a matching tool_result in your next message, whether it succeeded or failed. Omitting it breaks the conversation state.
Should I retry a tool call myself or let Claude retry it? Retry transient technical failures (network errors, timeouts) inside your own code. For invalid or malformed inputs, return an is_error result and let Claude generate a corrected input on its next turn.
How do I know if a tool error is coming from Claude's input or my own code? Log the raw tool input alongside the error message. If the input is well-formed and the failure is downstream (API down, DB timeout), it's an execution error. If the input itself is invalid per your schema, it's an input validation error.