hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Error Handling and Resilience with @hectoday/http

The problem with errors

  • Why error handling matters
  • Project setup

Error fundamentals

  • JavaScript error types
  • Try-catch and error propagation
  • Async errors

Structured error handling

  • Custom error classes
  • A global error handler
  • Operational vs programmer errors

Resilience patterns

  • Retries
  • Timeouts
  • Circuit breakers
  • Fallbacks and degradation

Server lifecycle

  • Graceful shutdown
  • Uncaught exceptions and unhandled rejections
  • Health checks under failure

Putting it all together

  • Error handling checklist
  • Capstone: resilient e-commerce API

Async errors

Async changes everything

The try-catch patterns from the last lesson work great for synchronous code. But our e-commerce API is full of async operations: calling the payment service, sending emails, checking inventory. Async code has different rules when it comes to errors, and those rules trip up a lot of developers.

Promises reject instead of throwing

Here is a mistake that looks reasonable but does not work:

// This does NOT catch the error
try {
  fetch("https://api.payment.com/charge"); // Returns a promise, does not throw
} catch (err) {
  console.log("Caught:", err); // Never runs
}
// The promise rejects later, after the try-catch has finished

What do you think happens here? The fetch call returns a promise immediately. At that moment, there is no error. The try-catch block checks for synchronous errors (there are none), finishes, and moves on. Later, the promise rejects, but the try-catch is already gone. The rejection has nowhere to go.

A promise does not throw an error. It rejects. A rejected promise without a .catch() or an await becomes an unhandled rejection. That is a different thing entirely from a thrown error.

async/await fixes this

await bridges the gap between promises and try-catch. When you await a promise, execution pauses until the promise settles. If the promise rejects, await converts that rejection into a thrown error that try-catch can handle:

// This DOES catch the error
try {
  await fetch("https://api.payment.com/charge"); // await converts rejection to throw
} catch (err) {
  console.log("Caught:", err); // This runs
}

The only difference is the await keyword. But it changes everything. Without await, the rejection escapes the try-catch. With await, the rejection is caught.

The rule is simple: always use await with promises inside try-catch. Without await, the rejection happens after the try-catch has finished, and your catch block never runs.

Multiple awaits

When you have several async operations in a row, each await can throw. A single try-catch handles all of them:

async function processOrder(userId: string, items: any[]) {
  try {
    const order = createOrder(userId, items);
    await chargeCard(order.total, "tok_123"); // Can throw: payment fails
    await sendEmail(userId, "Order confirmed", "Your order is on the way."); // Can throw: email service down
    await reserveStock("prod-1", 2); // Can throw: inventory service error
    return order;
  } catch (err) {
    // Any of the three awaits might have thrown
    console.error("Order processing failed:", err.message);
    throw err; // Re-throw for the route handler
  }
}

If chargeCard fails, execution jumps straight to the catch block. sendEmail and reserveStock never run. The error goes directly to the catch.

This is sequential execution. Each operation waits for the previous one to finish. If the first one fails, the rest are skipped. That is usually what you want for operations that depend on each other (you do not want to send a confirmation email if the payment failed).

Promise.all and errors

Sometimes you want to run multiple async operations at the same time instead of one after another. Promise.all does that:

try {
  const [product, inventory, price] = await Promise.all([
    fetchProduct("prod-1"),
    checkInventory("prod-1"),
    getLatestPrice("prod-1"),
  ]);
} catch (err) {
  // One of the three promises rejected
  // The other two might still be running (their results are discarded)
  console.error("Failed:", err.message);
}

Promise.all runs all three promises concurrently. If any one of them rejects, Promise.all rejects immediately with that error. The catch block runs.

[!WARNING] When Promise.all rejects, the other promises keep running in the background. Their results are just discarded. If those promises have side effects (like charging a credit card), the side effects still happen. Use Promise.allSettled if you need to know the result of every promise regardless of failures.

Promise.allSettled

Unlike Promise.all (which fails fast on the first rejection), Promise.allSettled waits for every promise to complete and reports each result individually:

const results = await Promise.allSettled([
  chargeCard(order.total, "tok_123"),
  sendEmail(userId, "Order confirmed", "Your order is on the way."),
  reserveStock("prod-1", 2),
]);

for (const result of results) {
  if (result.status === "fulfilled") {
    console.log("Success:", result.value);
  } else {
    console.error("Failed:", result.reason.message);
  }
}

Each result has a status property that is either "fulfilled" (success) or "rejected" (failure). For fulfilled results, the data is in result.value. For rejected results, the error is in result.reason.

Use Promise.allSettled when you want to attempt multiple operations and handle each result independently. For example, sending notifications to multiple users where some might fail but you still want to send the rest.

The unhandled rejection problem

What happens when a promise rejects and nothing handles it? You get a warning in the console:

// This creates an unhandled rejection
async function doSomething() {
  throw new Error("Oops");
}

doSomething(); // No await, no .catch()

The function returns a rejected promise. Nothing handles it. Node.js logs:

UnhandledPromiseRejectionWarning: Error: Oops

This is a bug. It means an error happened and your code did not deal with it. The fix is straightforward: always await async functions or add .catch():

await doSomething(); // Option 1: await (errors handled by enclosing try-catch)
doSomething().catch(console.error); // Option 2: .catch() (fire-and-forget with logging)

Option 1 is for operations you need to wait for. Option 2 is for fire-and-forget operations where you want to log errors but not block execution. We will use both patterns throughout the course.

Exercises

Exercise 1: Write a function that calls fetch without await inside a try-catch. Verify the error is NOT caught.

Exercise 2: Add await. Verify the error IS caught.

Exercise 3: Use Promise.allSettled to call three simulated services. Log which succeeded and which failed.

We now understand the fundamentals: error types, propagation, and how async errors work. In the next section, we will build structured error handling. Instead of throwing generic Error objects with string messages, we will create helper functions that build consistent error responses, so every route returns errors in the same format.

Why does try-catch not catch promise rejections without await?

← Try-catch and error propagation Custom error classes →

© 2026 hectoday. All rights reserved.