Claude API Tool Use Error Handling Pattern Guide
When you give Claude tools to call, things go wrong in predictable ways: the model asks for an argument your function doesn't support, your tool execution throws, a downstream API times out, or Claude generates invalid JSON for a parameter. The right pattern isn't to crash the request — it's to catch the failure and feed it back to Claude as a tool result, so the model can retry, adjust, or explain the problem to the user.
This article walks through a concrete error handling pattern for Claude API tool use: where errors come from, how to structure tool_result blocks for failures, when to retry versus give up, and how to avoid infinite tool-call loops.
Where tool use errors actually come from
Before writing a handler, it helps to categorize the failure modes you'll hit:
- Malformed tool call arguments — Claude returns JSON that doesn't match your schema, or omits a required field.
- Tool execution errors — your function runs but the underlying operation fails (API timeout, database error, permission denied).
- Invalid business logic state — the call is syntactically valid but semantically wrong (e.g., "cancel order #123" when the order doesn't exist).
- Network/infrastructure errors — your own service is down, rate-limited, or the upstream dependency is unreachable.
- Model hallucination — Claude invents a tool that doesn't exist or calls a tool it shouldn't have access to in context.
Each category needs a slightly different response, but they all share the same delivery mechanism: a tool_result content block with is_error: true.
The core pattern: errors as tool results, not exceptions
The key idea is that Claude never sees your server's stack trace. It sees a conversation. So when a tool call fails, you don't throw — you construct a tool_result block describing the failure and send it back in the next request, just like a successful result.
async function executeTool(toolUse) {
try {
const result = await runTool(toolUse.name, toolUse.input);
return {
type: "tool_result",
tool_use_id: toolUse.id,
content: JSON.stringify(result)
};
} catch (err) {
return {
type: "tool_result",
tool_use_id: toolUse.id,
content: `Error: ${err.message}`,
is_error: true
};
}
}
The is_error: true flag matters. Claude treats error results differently in its reasoning — it's more likely to apologize, retry with different arguments, or ask the user for clarification instead of presenting the error output as if it were valid data.
Handling each failure category
Malformed arguments
If toolUse.input fails validation against your schema (missing fields, wrong types), don't attempt to run the function. Return a descriptive error immediately so Claude can self-correct:
function validateInput(schema, input) {
const missing = schema.required.filter(k => !(k in input));
if (missing.length > 0) {
throw new Error(`Missing required fields: ${missing.join(", ")}`);
}
}
Being specific in the error message is what makes retries work. "Error: invalid input" gives Claude nothing to act on. "Error: missing required field 'customer_id'" lets it regenerate the call correctly on the next turn.
Execution failures
Wrap the actual side-effecting call (API request, DB query) in a try/catch and classify the error before returning it:
try {
await paymentApi.charge(toolUse.input);
} catch (err) {
if (err.code === "TIMEOUT") {
return errorResult(toolUse.id, "Payment service timed out, please retry.");
}
if (err.status === 402) {
return errorResult(toolUse.id, "Payment declined: insufficient funds.");
}
return errorResult(toolUse.id, `Payment failed: ${err.message}`);
}
This classification matters because some failures are retryable (timeouts, rate limits) and some aren't (declined payment, nonexistent record). You want Claude — and your own retry logic — to treat them differently.
Retryable vs terminal errors
Build a small helper that decides whether to let Claude retry automatically or whether to stop and surface the issue to the user:
const RETRYABLE = new Set(["TIMEOUT", "RATE_LIMITED", "503"]);
function shouldAutoRetry(errorCode, attempt) {
return RETRYABLE.has(errorCode) && attempt < 2;
}
For retryable errors, just return the tool_result with is_error: true and let Claude decide — it will often retry the call with the same or adjusted arguments on its own. For terminal errors, add explicit instructions in the error content, like "Do not retry this operation. Inform the user that the account is suspended," so Claude doesn't loop.
Preventing infinite tool-call loops
A common bug: Claude keeps calling the same failing tool because nothing tells it to stop. Guard against this at the orchestration level, not just in the prompt:
const MAX_TOOL_ROUNDS = 5;
let rounds = 0;
while (response.stop_reason === "tool_use" && rounds < MAX_TOOL_ROUNDS) {
const toolResults = await Promise.all(
response.content
.filter(c => c.type === "tool_use")
.map(executeTool)
);
messages.push({ role: "assistant", content: response.content });
messages.push({ role: "user", content: toolResults });
response = await callClaude(messages);
rounds++;
}
If you hit MAX_TOOL_ROUNDS, break out and return a fallback message to the user rather than letting the loop run indefinitely — this caps both latency and cost.
Unknown or disallowed tools
If Claude calls a tool name you don't recognize (stale cache, prompt drift, or a tool definition that was removed), return an error rather than ignoring the call silently:
if (!toolRegistry[toolUse.name]) {
return errorResult(toolUse.id, `Unknown tool: ${toolUse.name}`);
}
This keeps the conversation state consistent — every tool_use block requires a matching tool_result, or the next API call will fail with a validation error.
Logging and observability
Tool errors are worth logging separately from general API errors because they tell you about prompt quality, not infrastructure health. Track: tool name, error category, raw input, and whether Claude self-corrected on the next turn. If one tool generates malformed arguments repeatedly, that's a signal to tighten its JSON schema description, not just a one-off bug.
If you're running tool-using agents through SubToAPI, the same pattern applies directly — tool calls and results flow through the standard /v1/messages format, so your error-handling code doesn't need to change based on which backend is serving the requests. See the tool use docs and messages API reference for the exact request/response shapes, or start with the quickstart if you're setting this up for the first time.
A minimal checklist
- Validate tool arguments before execution, return specific error messages on failure
- Always return a
tool_resultfor everytool_use, success or failure - Set
is_error: trueon failures so Claude reasons about them correctly - Classify errors as retryable or terminal and say so explicitly in the error content
- Cap tool-call rounds to avoid infinite loops
- Log tool errors separately to catch recurring schema or prompt issues
questions
Does Claude automatically retry a failed tool call? Not on its own — it reasons based on the tool_result you send back. If the error message is clear and the failure looks retryable, Claude will often generate a corrected call in the next turn, but you control whether that loop continues.
What's the difference between is_error: true and just returning an error string? is_error: true is a structured signal in the tool_result block that tells Claude the operation failed, independent of the text content. Without it, Claude may treat the error message as literal data instead of a failure to react to.
Should I validate tool arguments on the client or just let the tool function fail? Validate before execution whenever possible. It's faster, avoids side effects from partially invalid calls, and lets you return precise, actionable error messages that help Claude self-correct on the next turn.