Create your own
Lesson illustration

Handling API Errors Gracefully

Welcome back! In our last lesson, you mastered the art of transforming raw API data into polished, user-friendly messages. You've seen how patterns from your front-end work, like mapping over data arrays, apply directly to bot development. Now, our bot can fetch and display information beautifully, but what happens when the digital world doesn't cooperate?

So far, our error handling has been a simple try...catch block with generic messages. This is a good start, but for a production-quality bot, it's insufficient. When an API call fails, the user is left in the dark with a vague "Sorry, an error occurred," and as the developer, you lack the detailed logs needed to diagnose the problem.

This lesson is all about elevating our error handling from basic to robust. We will dissect the different types of failures that can occur when communicating with an external API and implement strategies to handle each one gracefully. You'll learn to provide clear, helpful feedback to your users while ensuring you get the rich diagnostic information you need. We'll move beyond simple try...catch blocks to build resilient, professional-grade API integrations.

Dissecting API Failures

Let's revisit the /posts command from our previous lesson. The current error handling looks something like this:

// Inside the /posts command handler
try {
  const response = await fetch('...');

  if (!response.ok) {
    // Catches HTTP errors like 404 or 500
    await ctx.reply("Sorry, I couldn't fetch the posts right now.");
    return;
  }

  const posts = await response.json();
  // ... formatting logic ...

} catch (error) {
  // Catches network errors, or crashes in our own code
  console.error("Error fetching posts:", error);
  await ctx.reply("An unexpected error occurred.");
}

This code lumps many different failure scenarios together:

  1. Network Errors: What if the API server is completely unreachable, or DNS fails? The fetch call itself will throw an error, landing in the catch block.
  2. HTTP Status Errors: The server is reachable, but it returns an error status code. A 404 Not Found is very different from a 502 Bad Gateway. Our if (!response.ok) block catches these but treats them all the same.
  3. Parsing Errors: The server returns a 200 OK status, but the body isn't valid JSON. The response.json() call will throw an error, also landing in the catch block.

To improve this, our first step is to provide more specific feedback. We can inspect the response.status to give the user a better idea of what went wrong.

// A more refined error-checking block
if (!response.ok) {
  let userMessage = "Sorry, an API error occurred.";
  if (response.status === 404) {
    userMessage = "The requested content could not be found.";
  } else if (response.status >= 500) {
    userMessage = "The external service is currently down. Please try again later.";
  }
  await ctx.reply(userMessage);
  console.error(`API Error: ${response.status} ${response.statusText}`);
  return;
}

This is an improvement, but handling this logic inside every command that makes an API call leads to code duplication. A much better approach, common in robust client-side applications, is to create a reusable wrapper function.

A Robust safeFetch Wrapper

Let's create a safeFetch function that encapsulates all the common error-handling logic for API requests. This function will be our single source of truth for making external calls.

The blog post "How to Handle HTTP Requests in Bun" provides an excellent pattern for this. It introduces a custom HttpError class and a wrapper function that gracefully handles network issues, non-ok HTTP statuses, and JSON parsing failures.

How to Handle HTTP Requests in Bun

This article from the OneUptime blog details several best practices for making HTTP requests. We will focus on its comprehensive error-handling pattern.

Please read the section titled "Comprehensive Error Handling". Pay close attention to the structure of the custom HttpError class and how the safeFetch function uses try...catch blocks to handle different failure stages: the network request itself, the HTTP status check, and the JSON parsing.

Let's adapt this pattern for our project. We'll create a new file, src/http.ts, to house our custom error and safeFetch function.

src/http.ts

// Custom error class for HTTP errors
export class HttpError extends Error {
  constructor(
    public status: number,
    public statusText: string,
    public url: string,
    public responseBody?: string,
  ) {
    super(`HTTP ${status} ${statusText} for ${url}`);
    this.name = "HttpError";
  }
}

// Robust fetch wrapper with comprehensive error handling
export async function safeFetch<T>(
  url: string,
  options: RequestInit = {},
): Promise<T> {
  let response: Response;
  try {
    response = await fetch(url, options);
  } catch (error) {
    // Network errors (DNS failure, connection refused, etc.)
    if (error instanceof TypeError) {
      throw new Error(`Network error: Unable to connect to ${url}`);
    }
    throw error;
  }

  // Handle HTTP errors
  if (!response.ok) {
    let errorBody: string | undefined;
    try {
      errorBody = await response.text();
    } catch {
      // Ignore errors reading error body
    }
    throw new HttpError(response.status, response.statusText, url, errorBody);
  }

  // Parse JSON response
  try {
    return (await response.json()) as T;
  } catch (error) {
    throw new Error(`Failed to parse JSON response from ${url}`);
  }
}

Now, we can refactor our /posts command to use this clean, reusable utility.

src/bot.ts

// at the top of the file
import { safeFetch, HttpError } from './http';

// ...

bot.command("posts", async (ctx) => {
  try {
    const posts = await safeFetch<{ id: number; title: string; body: string }[]>(
      'https://jsonplaceholder.typicode.com/posts?_limit=5'
    );
    
    const message = posts
      .map(post => `<b>${post.id}. ${post.title}</b>`)
      .join('\n');
      
    await ctx.reply(message, { parse_mode: "HTML" });

  } catch (error) {
    console.error("Error in /posts command:", error);

    if (error instanceof HttpError) {
      let userMessage = "An API error occurred. Please try again.";
      if (error.status === 404) {
        userMessage = "Could not find the requested posts.";
      } else if (error.status >= 500) {
        userMessage = "The posts service is temporarily unavailable.";
      }
      await ctx.reply(userMessage);
    } else {
      // For network errors or JSON parsing errors from safeFetch
      await ctx.reply(`An unexpected error occurred: ${error.message}`);
    }
  }
});

This is a significant improvement. Our command handler is now cleaner, its try...catch block can handle specific, typed errors, and our core request logic is centralized and reusable.

Beyond try...catch: The Result Pattern

The try...catch mechanism is fundamental in JavaScript, but it has drawbacks. Errors are "thrown" and control flow jumps to the nearest catch block, which can sometimes make code harder to reason about, especially in complex async functions. A powerful alternative, popular in functional programming and increasingly in TypeScript, is the "Result" pattern.

Instead of throwing an error, a function explicitly returns a value that represents either success or failure. This makes error handling a visible, type-safe part of the function's return signature.

The video "How To Handle Errors Like A Senior Dev" provides an exceptional deep-dive into this concept, starting with custom error classes and culminating in a full-fledged, type-safe result pattern.

How To Handle Errors Like A Senior Dev

This video from Web Dev Simplified is a masterclass in evolving error-handling strategies. It perfectly captures the journey from basic error checking to a highly robust and type-safe architecture.

Please watch these key segments to understand this powerful pattern: The Problem: Watch the introduction from the start to see why duplicated error logic in different contexts (e.g., a web UI and an API route) becomes unmanageable. Custom Errors: See how custom error classes allow you to differentiate between error types in a catch block, as we did with HttpError. Watch the segment from this explanation. The Result Type: This is the core concept. Watch from this segment to understand the idea of a function returning a result object (or tuple) that contains either data or an error, but not both. Implementation: See how this is implemented from scratch, enforcing type safety. Watch from this section. Consumption: Finally, see how to consume a function that returns a result. Notice how TypeScript's type narrowing makes the code clean and safe. Watch from this walkthrough.

While implementing a full neverthrow-style library might be overkill for our current needs, the core idea is invaluable. We could refactor safeFetch to return a result tuple [data, error], a pattern popularized by Go and made easy in TypeScript. This avoids try...catch entirely at the call site.

grammY's Global Safety Net: bot.catch

So far, we've focused on handling errors within a specific command handler to provide contextual feedback. But what about unexpected errors that we didn't anticipate? Or errors that happen within grammY's middleware stack before our handler even runs?

For this, grammY provides a global error handler: bot.catch. This acts as a final safety net, catching any error that isn't handled elsewhere. It's the perfect place for centralized logging and for providing a generic "something went wrong" message to the user, or even notifying you as the administrator.

The grammY documentation explains this feature and the different error types you might encounter.

Error Handling | grammY

This is the official documentation for grammY's error handling mechanisms. It's essential for building a resilient bot.

Please read the following sections: Start with the introduction to see the overview and the three main error types: BotError, GrammyError, and HttpError. Focus on the "Long Polling" section, as this is how we are running our bot. Pay close attention to the example code. Read the descriptions for BotError, GrammyError, and HttpError to understand what each one signifies.

Let's add a bot.catch handler to our src/bot.ts. This should be placed just before bot.start().

src/bot.ts

// Import error classes from grammy at the top
import { Bot, GrammyError, HttpError as GrammyHttpError } from "grammy";
// also import our custom HttpError from './http'

// ... your bot setup and command handlers ...

// The global error handler
bot.catch((err) => {
  const ctx = err.ctx;
  console.error(`Error while handling update ${ctx.update.update_id}:`);
  
  const e = err.error;

  if (e instanceof GrammyError) {
    console.error("Error in request:", e.description);
  } else if (e instanceof GrammyHttpError) {
    console.error("Could not contact Telegram:", e);
  } else if (e instanceof HttpError) { // Our custom HttpError
    console.error(`External API Error: ${e.status} ${e.statusText}`, e);
  } else {
    console.error("Unknown error:", e);
  }
  
  // Optionally, send a message to the user
  if (ctx.chat?.id) {
    ctx.reply("An internal error occurred. My developers have been notified.");
  }
});


// ... bot.start()

With this in place, our bot has two layers of defense:

  1. Local Handling: try...catch blocks within command handlers using safeFetch provide specific, graceful feedback for predictable API failures.
  2. Global Handling: bot.catch acts as a safety net, logging any unexpected errors and preventing the bot from crashing.

Conclusion

You've now equipped your bot with a robust, multi-layered error-handling strategy. This is a critical step in moving from a prototype to a reliable application that users can depend on.

Key Takeaways:

  • Effective error handling requires distinguishing between network errors, HTTP status errors, and data parsing errors.
  • Creating a reusable safeFetch wrapper centralizes request logic and error handling, keeping your command handlers clean and focused.
  • Custom error classes, like our HttpError, allow you to use instanceof for typed, specific error-handling logic in catch blocks.
  • The "Result" pattern offers a powerful, type-safe alternative to try...catch for managing success and failure states explicitly.
  • grammY's bot.catch provides an essential global safety net to log unhandled exceptions and prevent your bot from crashing.

We have now covered almost all the fundamental building blocks. You can manage dependencies with Bun, interact with the Telegram API, handle user commands, format data, and now, do it all resiliently.

In our next module, we will put all these skills to the test in our capstone project: building a sophisticated AI assistant powered by the Anthropic Claude API. The first step will be setting up the Anthropic SDK and configuring API credentials, getting our project ready for this exciting integration.

Can't find a good explanation? Sign up and we'll make it for you

Sign up