I was doing a live demo. The agent called a weather API. The API returned a 503. The agent replied with [object Object].
The demo died. The client was confused. And the worst part — this failure was completely avoidable. I just hadn’t thought about what happens when things go wrong.
Error handling in agent skills is different from error handling in regular code. Understanding why is what makes the difference between a skill that works and one that silently breaks your agent.
Why tool failures are different
In normal code, an unhandled error throws an exception, crashes the process, and you see a stack trace. You know something broke.
In an agent loop, the model doesn’t crash — it reads your error as data. If your tool throws an unhandled exception and the agent loop catches it poorly, the model might receive undefined, null, or [object Object] and attempt to respond as if that were real information.
Even worse: it might retry the same broken tool call in an infinite loop because nothing told it the tool failed.
The model is only as good as the information it receives. If your error handling is bad, the model will produce confidently wrong answers.
The three failure categories
Before writing any error handling code, it helps to know what kind of failure you’re dealing with.
Category 1 — Network errors
The API is unreachable, times out, or returns an HTTP error (4xx, 5xx). These are transient — retrying often succeeds.
// Network error examples
// - fetch() throws: "TypeError: fetch failed"
// - Response: 503 Service Unavailable
// - Response: 429 Too Many Requests (rate limited)
// - Response: timeout after 30s
Category 2 — Bad data
The API responds successfully but the data is wrong, empty, or malformed.
// Bad data examples
// - geo.results is undefined (city not found)
// - response.json() throws (non-JSON body)
// - fields are null when code expects strings
// - array is empty when code expects at least one item
Category 3 — Logic errors
Your code has a bug, or the inputs the model provided are invalid.
// Logic error examples
// - model passed city: null (required field missing)
// - model passed city: 12345 (wrong type)
// - division by zero in a calculation
// - accessing property on undefined
Each category needs a different response. Let’s build patterns for all three.
Pattern 1 — Defensive return objects
The most important rule: never throw from a tool function. Always return an object, even when something goes wrong.
// ❌ Bad — throws an exception
async function get_weather({ city }) {
const response = await fetch(`https://api.example.com/weather?city=${city}`);
const data = await response.json(); // throws if response is not JSON
return data.current; // throws if data.current is undefined
}
// ✅ Good — always returns an object
async function get_weather({ city }) {
try {
const response = await fetch(`https://api.example.com/weather?city=${city}`);
if (!response.ok) {
return { error: `Weather API returned ${response.status}. Try again later.` };
}
const data = await response.json();
if (!data.current) {
return { error: `No weather data found for "${city}".` };
}
return {
city: data.location.name,
temperature: `${data.current.temp_c}°C`,
condition: data.current.condition.text
};
} catch (err) {
return { error: `Could not reach weather service: ${err.message}` };
}
}
When the model receives { error: "Weather API returned 503. Try again later." }, it can respond meaningfully: “I couldn’t get the weather right now — the service seems to be temporarily unavailable. Want me to try again?”
When it receives [object Object], it has nothing to work with.
Pattern 2 — Retry with exponential backoff
Transient network errors are temporary. Retrying after a short delay resolves them most of the time. Here’s a reusable wrapper:
async function withRetry(fn, maxAttempts = 3, baseDelayMs = 500) {
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const result = await fn();
// If the function returned an error object on a retryable condition, retry
if (result?.error && result?.retryable) {
throw new Error(result.error);
}
return result;
} catch (err) {
lastError = err;
if (attempt < maxAttempts) {
// Exponential backoff: 500ms, 1000ms, 2000ms...
const delay = baseDelayMs * Math.pow(2, attempt - 1);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
return { error: `Failed after ${maxAttempts} attempts: ${lastError.message}` };
}
Wrap your tool call:
async function get_weather({ city }) {
return withRetry(async () => {
const response = await fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`
);
if (response.status === 429) {
return { error: "Rate limited", retryable: true };
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
// ... rest of the logic
return { city: data.results[0].name, temperature: "..." };
});
}
The retryable: true flag lets the wrapper know to retry that specific error. Regular throw also triggers a retry. An error object without retryable returns immediately.
Pattern 3 — Fallbacks
When the primary data source fails, try a secondary one. When that fails too, return a graceful degraded response.
async function get_weather({ city }) {
// Primary source: Open-Meteo (free, no key)
const primary = await tryOpenMeteo(city);
if (!primary.error) return primary;
// Secondary source: wttr.in (also free, different format)
const secondary = await tryWttr(city);
if (!secondary.error) return secondary;
// Both failed — return a useful degraded response
return {
city,
temperature: "unavailable",
condition: "Weather data is temporarily unavailable. Please check a weather app directly.",
degraded: true
};
}
async function tryOpenMeteo(city) {
try {
const geo = await fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`
).then(r => r.json());
if (!geo.results?.length) return { error: "City not found" };
const { latitude, longitude, name } = geo.results[0];
const weather = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t_weather=true`
).then(r => r.json());
return { city: name, temperature: `${weather.current_weather.temperature}°C`, source: "open-meteo" };
} catch (err) {
return { error: err.message };
}
}
async function tryWttr(city) {
try {
const data = await fetch(
`https://wttr.in/${encodeURIComponent(city)}?format=j1`
).then(r => r.json());
const current = data.current_condition[0];
return {
city,
temperature: `${current.temp_C}°C`,
condition: current.weatherDesc[0].value,
source: "wttr.in"
};
} catch (err) {
return { error: err.message };
}
}
The model receives real, useful data even when the primary source is down. And if both sources fail, it gets a honest degraded: true result it can explain clearly to the user.
When to surface the error to the model
Not all errors should be explained in detail. Here’s the decision:
| Situation | What to return | Why |
|---|---|---|
| Transient failure (rate limit, 503) | { error: "Service temporarily unavailable. Try again in a moment." } | Model can relay this and suggest retry |
| City / resource not found | { error: "No results found for 'Atlantis'. Try a different city name." } | Model can ask the user to clarify |
| Auth failure (expired API key) | { error: "Weather service authentication failed. Contact support." } | Useful for debugging, not user-actionable |
| Degraded fallback worked | Include degraded: true field | Model can note data may be approximate |
| Critical logic error | { error: "Internal error. Please try a different request." } | Don’t leak stack traces to the model |
The model will use whatever you give it. Write error messages for the person who will read the model’s response — not for a developer reading logs.
What the model sees: throw vs error object
Here’s a direct comparison. Same broken API call, different error handling:
With unhandled throw:
[Agent loop error: TypeError: Cannot read properties of undefined (reading 'temperature')]
The model receives nothing useful. It might hallucinate a weather report. It might say “I apologize, I encountered an error” repeatedly.
With defensive return:
{
"error": "Weather service temporarily unavailable. Both primary and backup sources failed.",
"degraded": true
}
The model responds: “I wasn’t able to get current weather data for Mumbai — both the services I use seem to be down right now. You can check weather.com directly, or I can try again in a few minutes if you’d like.”
That’s a good agent response. The user knows what happened and what to do.
Full working example
Here’s get_weather with all three patterns applied:
// weather-tool-robust.js
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
// --- Error handling utilities ---
async function withRetry(fn, maxAttempts = 3, baseDelayMs = 500) {
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const result = await fn();
if (result?.error && result?.retryable) throw new Error(result.error);
return result;
} catch (err) {
lastError = err;
if (attempt < maxAttempts) {
await new Promise(r => setTimeout(r, baseDelayMs * Math.pow(2, attempt - 1)));
}
}
}
return { error: `Failed after ${maxAttempts} attempts: ${lastError.message}` };
}
// --- Tool implementation ---
async function get_weather({ city }) {
if (!city || typeof city !== "string") {
return { error: "City name is required and must be a string." };
}
return withRetry(async () => {
const geo = await fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`
);
if (geo.status === 429) return { error: "Rate limited", retryable: true };
if (!geo.ok) throw new Error(`Geocoding API: HTTP ${geo.status}`);
const geoData = await geo.json();
if (!geoData.results?.length) {
return { error: `No location found for "${city}". Check the spelling and try again.` };
}
const { latitude, longitude, name, country } = geoData.results[0];
const weather = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t_weather=true&hourly=relativehumidity_2m`
);
if (!weather.ok) throw new Error(`Weather API: HTTP ${weather.status}`);
const wData = await weather.json();
const current = wData.current_weather;
const codes = {
0: "Clear sky", 1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast",
61: "Light rain", 63: "Moderate rain", 65: "Heavy rain", 95: "Thunderstorm"
};
return {
city: `${name}, ${country}`,
temperature: `${current.temperature}°C`,
condition: codes[current.weathercode] ?? "Unknown",
humidity: `${wData.hourly.relativehumidity_2m[0]}%`
};
});
}
// --- Agent loop ---
const tools = [{
name: "get_weather",
description: "Get current weather for a city. Use when the user asks about weather, temperature, or rain.",
input_schema: {
type: "object",
properties: { city: { type: "string", description: "City name" } },
required: ["city"]
}
}];
async function chat(userMessage) {
const messages = [{ role: "user", content: userMessage }];
let response = await client.messages.create({ model: "claude-sonnet-4-6", max_tokens: 1024, tools, messages });
while (response.stop_reason === "tool_use") {
const toolBlock = response.content.find(b => b.type === "tool_use");
const result = await get_weather(toolBlock.input);
messages.push(
{ role: "assistant", content: response.content },
{ role: "user", content: [{ type: "tool_result", tool_use_id: toolBlock.id, content: JSON.stringify(result) }] }
);
response = await client.messages.create({ model: "claude-sonnet-4-6", max_tokens: 1024, tools, messages });
}
return response.content[0].text;
}
console.log(await chat("What's the weather in Mumbai?"));
What’s next
Test your skills before deploying: Testing and Debugging Agent Skills Before You Deploy
Chain multiple skills together: Chaining Agent Skills: Research, Summarize, and Save
Back to fundamentals: What Are Agent Skills? AI Tools Explained Simply
Related Reading.
Testing and Debugging Agent Skills Before You Deploy
Skills that work alone fail differently inside an agent loop. Unit test your tools, mock AI calls, and debug the full tool_use cycle in Node.js.
Agent Skills with Google Gemini: Function Calling Guide
Complete guide to Gemini function calling — define tools, handle function_call responses, return results, and compare syntax with Claude and OpenAI. Node.js.
Vercel AI SDK Tools: One API for Claude and OpenAI Skills
Vercel AI SDK's unified tool interface works with Claude, OpenAI, and Gemini. Write your skill once and switch AI providers without rewriting the agent loop.