M
MeshWorld.
Agent Skills Agentic AI Tutorial Node.js Error Handling Beginners 10 min read

Handling Errors in Agent Skills: Retries and Fallbacks

By Vishnu Damwala

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}&current_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:

SituationWhat to returnWhy
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 workedInclude degraded: true fieldModel 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}&current_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