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

Project setup

The domain

Now that we know why error handling matters, let’s set up the project we will build throughout this course. We need a domain where errors happen naturally. Not just one or two edge cases, but a domain where failure is part of everyday operation. E-commerce is perfect for that.

Think about what happens when someone places an order. The server needs to validate the input, check inventory, charge a credit card through an external payment service, send a confirmation email, and update the database. Each of those steps can fail. The payment gateway can time out. The email service can go down. The inventory system can return an error. Every error scenario we want to learn about happens naturally in this domain.

We are going to build this API step by step. By the end of this lesson, you will have a working server that can list products and place orders. It will also break in spectacular ways, which is exactly the point. Fixing those failures is the rest of the course.

Create the project

Code along
mkdir resilient-api
cd resilient-api
npm init -y
npm install @hectoday/http zod srvx better-sqlite3
npm install -D typescript @types/node @types/better-sqlite3 tsx

Same stack as every course in this series. @hectoday/http handles routing. zod handles validation (we import from zod/v4 to match the types that @hectoday/http expects). better-sqlite3 gives us a database. srvx is a small library that starts an HTTP server from a fetch function. And tsx lets us run TypeScript directly without a separate build step.

Create tsconfig.json in the project root:

Code along
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "rootDir": "./src",
    "outDir": "dist",
    "types": ["node"]
  },
  "include": ["src"]
}

Then open package.json and add "type": "module" at the top level, plus a dev script:

Code along
{
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/server.ts"
  }
}

The dev script uses tsx watch, which runs our server and automatically restarts it whenever we save a file. You will keep this running in a terminal throughout the course.

The database

Our e-commerce API needs three tables: products, orders, and order items. Create src/db.ts:

Code along
// src/db.ts
import Database from "better-sqlite3";

const db = new Database("shop.db");
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
db.pragma("busy_timeout = 5000");

db.exec(`
  CREATE TABLE IF NOT EXISTS products (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    price REAL NOT NULL CHECK (price > 0),
    stock INTEGER NOT NULL DEFAULT 0,
    created_at TEXT NOT NULL DEFAULT (datetime('now'))
  );

  CREATE TABLE IF NOT EXISTS orders (
    id TEXT PRIMARY KEY,
    user_id TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'pending',
    total REAL NOT NULL,
    created_at TEXT NOT NULL DEFAULT (datetime('now'))
  );

  CREATE TABLE IF NOT EXISTS order_items (
    order_id TEXT NOT NULL,
    product_id TEXT NOT NULL,
    quantity INTEGER NOT NULL CHECK (quantity > 0),
    price REAL NOT NULL,
    PRIMARY KEY (order_id, product_id),
    FOREIGN KEY (order_id) REFERENCES orders(id),
    FOREIGN KEY (product_id) REFERENCES products(id)
  );
`);

// Seed data
const existing = db.prepare("SELECT id FROM products LIMIT 1").get();
if (!existing) {
  db.prepare("INSERT INTO products (id, name, price, stock) VALUES (?, ?, ?, ?)").run(
    "prod-1",
    "Mechanical Keyboard",
    89.99,
    50,
  );
  db.prepare("INSERT INTO products (id, name, price, stock) VALUES (?, ?, ?, ?)").run(
    "prod-2",
    "USB-C Hub",
    34.99,
    100,
  );
  db.prepare("INSERT INTO products (id, name, price, stock) VALUES (?, ?, ?, ?)").run(
    "prod-3",
    "Monitor Stand",
    49.99,
    0,
  ); // Out of stock
}

export default db;

Let’s walk through what this does.

We create three tables. The products table stores items in the shop. Each product has an ID, a name, a price (which must be greater than zero, enforced by a CHECK constraint), and a stock count. The orders table tracks who ordered what and the current status of the order. The order_items table connects orders to products. It stores how many of each product were ordered and at what price. Its primary key is a combination of order_id and product_id, meaning each product can only appear once per order (the quantity column handles multiples).

The three pragma calls at the top configure SQLite behavior. journal_mode = WAL enables write-ahead logging for better concurrent performance. foreign_keys = ON enforces the foreign key relationships between tables, so you cannot create an order item that references a product that does not exist. busy_timeout = 5000 tells SQLite to wait up to 5 seconds if the database is locked instead of failing immediately.

[!NOTE] The pragmas (journal_mode = WAL, foreign_keys = ON, busy_timeout = 5000) are explained in detail in the Database Design course’s SQLite Pragmas lesson.

Then we seed the database with three products. Notice that prod-3 (Monitor Stand) has a stock of 0. That is intentional. We will use it later to test what happens when someone tries to order an out-of-stock item.

External services (simulated)

Before we write the routes, we need the external services that the order flow depends on. In production, this app would call a payment processor like Stripe, an email service like SendGrid, and an inventory management system. We are not going to set up real accounts for any of those. Instead, we will simulate them.

Why simulate? Because real services fail unpredictably. You might go hours without seeing an error, or you might get ten in a row. That makes it impossible to reliably test error handling. Our simulated services fail at controlled rates, so we can trigger every error path on demand.

Create src/services/payment.ts:

Code along
// src/services/payment.ts
export async function chargeCard(amount: number, cardToken: string): Promise<{ chargeId: string }> {
  await new Promise((r) => setTimeout(r, 200));

  if (Math.random() < 0.2) {
    throw new Error("Payment gateway timeout");
  }

  return { chargeId: `ch_${crypto.randomUUID().slice(0, 8)}` };
}

The chargeCard function takes an amount and a cardToken. It waits 200 milliseconds to simulate network latency (real payment APIs are not instant), then fails 20% of the time by throwing an error. When it succeeds, it returns a fake charge ID. The crypto.randomUUID() call generates a unique ID each time, just like a real payment processor would.

Create src/services/email.ts:

Code along
// src/services/email.ts
export async function sendEmail(to: string, subject: string, body: string): Promise<void> {
  await new Promise((r) => setTimeout(r, 100));

  if (Math.random() < 0.1) {
    throw new Error("Email service unavailable");
  }
}

The email service is simpler. It takes a recipient, subject, and body, waits 100 milliseconds, and fails 10% of the time. Notice it returns nothing on success. Sending an email is a fire-and-forget operation.

Create src/services/inventory.ts:

Code along
// src/services/inventory.ts
export async function reserveStock(productId: string, quantity: number): Promise<void> {
  await new Promise((r) => setTimeout(r, 50));

  if (Math.random() < 0.05) {
    throw new Error("Inventory service connection refused");
  }
}

The inventory service is the most reliable at a 5% failure rate, but it still fails. These numbers reflect real-world patterns. Payment services tend to be the most failure-prone because they depend on third-party networks. Inventory systems are usually internal and more reliable. Email sits somewhere in between.

The important thing is that the error handling patterns we build work exactly the same whether the failure is simulated or real. When you replace these with actual services later, the error handling code does not change.

App shell

With the database and services ready, let’s build the actual API. We will start with three routes: a health check, a way to list products, and the order flow. Create src/app.ts:

Code along
// src/app.ts
import { setup, route } from "@hectoday/http";
import { z } from "zod/v4";
import db from "./db.js";
import { chargeCard } from "./services/payment.js";
import { sendEmail } from "./services/email.js";
import { reserveStock } from "./services/inventory.js";

const OrderBody = z.object({
  userId: z.string(),
  productId: z.string(),
  quantity: z.number().int().positive(),
  paymentToken: z.string(),
});

export const app = setup({
  routes: [
    route.get("/health", {
      resolve: () => Response.json({ status: "ok" }),
    }),

    route.get("/products", {
      resolve: () => {
        const products = db.prepare("SELECT * FROM products").all();
        return Response.json(products);
      },
    }),

    route.get("/products/:id", {
      request: { params: z.object({ id: z.string() }) },
      resolve: (c) => {
        const product = db.prepare("SELECT * FROM products WHERE id = ?").get(c.input.params!.id);
        return Response.json(product);
      },
    }),

    route.post("/orders", {
      request: { body: OrderBody },
      resolve: async (c) => {
        const { userId, productId, quantity, paymentToken } = c.input.body!;

        const product = db.prepare("SELECT * FROM products WHERE id = ?").get(productId) as any;
        const total = product.price * quantity;

        await reserveStock(productId, quantity);
        const { chargeId } = await chargeCard(total, paymentToken);

        const orderId = `ord_${crypto.randomUUID().slice(0, 8)}`;
        db.prepare("INSERT INTO orders (id, user_id, status, total) VALUES (?, ?, ?, ?)").run(
          orderId,
          userId,
          "confirmed",
          total,
        );
        db.prepare(
          "INSERT INTO order_items (order_id, product_id, quantity, price) VALUES (?, ?, ?, ?)",
        ).run(orderId, productId, quantity, product.price);

        await sendEmail(userId, "Order confirmed", `Your order ${orderId} has been placed.`);

        return Response.json({ orderId, chargeId, total }, { status: 201 });
      },
    }),
  ],
});

Let’s walk through the /orders route, because it is the heart of this entire course.

The route expects a JSON body with four fields: userId, productId, quantity, and paymentToken. The OrderBody schema at the top defines the shape using Zod. The z.number().int().positive() on quantity means it must be a positive whole number.

Inside the handler, the first thing we do is look up the product from the database so we can calculate the total price. Then we call three external services in sequence: reserveStock to hold the inventory, chargeCard to process the payment, and sendEmail to send a confirmation.

Between those service calls, we create the order and order items in the database. The order gets a randomly generated ID (same pattern as the charge ID), and the status is set to "confirmed".

This route works. When everything goes well, it reserves stock, charges the card, saves the order, sends an email, and returns the order details. But what happens when something goes wrong?

Now create the server file. Create src/server.ts:

Code along
// src/server.ts
import { serve } from "srvx";
import { app } from "./app.js";

serve({ fetch: app.fetch, port: 3000 });

The serve function from srvx takes a fetch function and a port, and starts an HTTP server. app.fetch is the function that @hectoday/http gives us. It takes a standard Request and returns a Response. That is the same interface that runs on Cloudflare Workers, Deno, and Bun, so the code is portable.

Running the server

Start the development server:

npm run dev

You should see output indicating the server is running on port 3000. Open a second terminal and test the health check:

curl http://localhost:3000/health
{ "status": "ok" }

If you see that response, the server is working. Now verify the seed data loaded correctly:

curl http://localhost:3000/products
[
  {
    "id": "prod-1",
    "name": "Mechanical Keyboard",
    "price": 89.99,
    "stock": 50,
    "created_at": "..."
  },
  { "id": "prod-2", "name": "USB-C Hub", "price": 34.99, "stock": 100, "created_at": "..." },
  { "id": "prod-3", "name": "Monitor Stand", "price": 49.99, "stock": 0, "created_at": "..." }
]

Three products. Now let’s place an order:

curl -X POST http://localhost:3000/orders \
  -H "Content-Type: application/json" \
  -d '{"userId": "user-1", "productId": "prod-1", "quantity": 2, "paymentToken": "tok_visa"}'
{ "orderId": "ord_a1b2c3d4", "chargeId": "ch_e5f6g7h8", "total": 179.98 }

It works. The stock was reserved, the card was charged, the order was saved, and the confirmation email was sent. Run it again. And again. Eventually, you will see something like this:

Internal Server Error

No JSON. No error code. No helpful message. Just a raw error because the payment service threw and nothing caught it. Try it a few more times and you might hit the email or inventory failure too. This is our starting point: a working API that falls apart the moment anything goes wrong.

Where things break

Let’s count the problems in the /orders route.

No input validation. What happens if someone sends quantity: -5? Or omits productId entirely? The Zod schema validates the shape, but the route never checks c.input.ok. If validation fails, c.input.body is undefined, and the handler crashes with a TypeError.

No product check. If the product ID does not exist, db.prepare(...).get() returns undefined. The next line tries to read product.price, which is a TypeError. The server crashes.

No stock check. The Monitor Stand has zero stock, but nothing in the route verifies there is enough stock before placing the order.

No error handling on service calls. If chargeCard throws (20% of the time), the error propagates out of the handler with no catch. Same for reserveStock and sendEmail. The user gets a raw 500 error.

Partial operations. If the payment succeeds but the email fails, the order is saved but the user never gets a confirmation. If the stock reservation succeeds but the payment fails, the stock is reserved but never released.

That is five categories of failure in a single route. Over the next lessons, we will fix all of them. We will build error response helpers that return consistent error formats. We will add a global error handler that catches unexpected throws. We will wrap service calls in retries, timeouts, and circuit breakers. By the end, this route will handle every failure gracefully.

But first, we need to understand how JavaScript errors actually work. That is where we are headed next.

Project structure

Your project should look like this:

resilient-api/
  src/
    services/
      payment.ts
      email.ts
      inventory.ts
    app.ts
    db.ts
    server.ts
  package.json
  tsconfig.json

Keep the dev server running in a terminal. You will use it throughout the entire course.

Exercises

Exercise 1: Start the server with npm run dev. Hit /health, /products, and /products/prod-1 with curl. Verify everything returns the expected data.

Exercise 2: Place an order by POSTing to /orders with the curl command from above. Run it five or six times. You should see it succeed sometimes and fail sometimes. Count how many times it fails.

Exercise 3: Try ordering a product that does not exist: "productId": "prod-999". What error do you see? Is it helpful? Try sending "quantity": -5. What happens? These are the kinds of failures we will learn to handle properly.

Why do we simulate external service failures instead of using real services?

← Why error handling matters JavaScript error types →

© 2026 hectoday. All rights reserved.