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

A global error handler

The safety net

In the last lesson, we built two tools: response helpers that routes use to return errors directly, and error classes for when errors need to propagate up the call stack. The response helpers handle the expected cases. But what about the unexpected ones?

What happens when a database query throws because the connection dropped? What happens when a service function throws a NotFoundError that the route handler did not anticipate? What happens when a bug causes a TypeError?

These errors propagate out of the route handler. If nothing catches them, the server crashes or returns a raw error to the client. We need a safety net that catches every escaped error and turns it into a proper response.

That is what the global error handler does. It is the single place where all unhandled errors are caught, logged, and converted into HTTP responses.

The global handler

Here is the function. It takes an error and an optional request, and returns a Response:

Code along
// src/error-handler.ts
import { AppError } from "./errors.js";

export function handleError(err: unknown, request?: Request): Response {
  // Known application errors (thrown by service code)
  if (err instanceof AppError) {
    const url = request ? new URL(request.url) : null;
    console.log(
      JSON.stringify({
        level: "info",
        event: "error",
        code: err.code,
        message: err.message,
        statusCode: err.statusCode,
        method: request?.method,
        path: url?.pathname,
      }),
    );

    return Response.json(
      { error: { code: err.code, message: err.message } },
      { status: err.statusCode },
    );
  }

  // Unknown errors: these are bugs
  const url = request ? new URL(request.url) : null;
  console.error(
    JSON.stringify({
      level: "error",
      event: "unhandled_error",
      message: err instanceof Error ? err.message : String(err),
      stack: err instanceof Error ? err.stack : undefined,
      method: request?.method,
      path: url?.pathname,
    }),
  );

  // Never expose internal details to the client
  return Response.json(
    { error: { code: "INTERNAL_ERROR", message: "An unexpected error occurred" } },
    { status: 500 },
  );
}

Let’s walk through this step by step.

The function takes err: unknown because we do not know what type the error will be. It could be an AppError, a regular Error, or even a string (if someone threw a string, which they should not).

First, it checks if the error is an AppError using instanceof. If it is, we know it is one of our custom errors, so we have access to statusCode, code, and message. These are expected errors. A service function threw a NotFoundError because a record was missing. That is not a bug. We log it at info level and return a response with the correct status code.

If the error is not an AppError, we are in unknown territory. This is a bug: a TypeError, a ReferenceError, a database error, something we did not anticipate. We log everything (including the full stack trace) so the developer can investigate. But we return a generic “An unexpected error occurred” message to the client. We never expose the real error message or stack trace, because those contain internal details.

Wiring it into the app

Hectoday HTTP has an onError callback that catches errors thrown from any route. We wire our handler into it:

Code along
// src/app.ts
import { setup, route } from "@hectoday/http";
import { handleError } from "./error-handler.js";

export const app = setup({
  onError: ({ error, request }) => {
    return handleError(error, request);
  },
  routes: [
    route.get("/health", { resolve: () => Response.json({ status: "ok" }) }),
    // ... all routes
  ],
});

That is it. The onError callback catches any error thrown in any route handler and passes it to handleError. The callback receives three properties: error (the thrown value), request (the original Request object), and locals (any data returned by onRequest). We pass both error and request to handleError so our error logs include the request method and path.

Now here is how the two approaches work together:

import { notFound } from "../errors.js";
import db from "../db.js";
import { chargeCard } from "../services/payment.js";

route.post("/orders", {
  request: { body: OrderBody },
  resolve: async (c) => {
    if (!c.input.ok) {
      return Response.json(
        { error: { code: "VALIDATION_ERROR", message: "Invalid input", details: c.input.issues } },
        { status: 400 },
      );
    }

    const product = db
      .prepare("SELECT * FROM products WHERE id = ?")
      .get(c.input.body.productId) as any;
    if (!product) return notFound("Product");

    const total = product.price * c.input.body.quantity;

    // This might throw unexpectedly (network error, timeout, etc.)
    // The global error handler catches it
    const charge = await chargeCard(total, c.input.body.paymentToken);

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

The route returns errors directly for things it can anticipate: validation failures, missing resources. For unexpected failures (the payment service throwing), the error escapes the route handler and the global onError callback catches it. The user sees a clean 500 response instead of a crash.

[!NOTE] The REST API Design course’s error response format ({ error: { code, message, details } }) is generated by the response helpers for expected errors and by the global handler for unexpected ones. Every error across every route uses the same format.

What the handler does, summarized

For known errors (AppError subclasses thrown by service code):

  1. Log at info level (these are expected errors)
  2. Return a response with the error code, message, and correct status code

For unknown errors (anything else):

  1. Log the full error with stack trace at error level
  2. Return a generic “An unexpected error occurred” response with 500
  3. Never expose the real error message or stack trace to the client

Structured logging

Notice that the error handler logs errors as JSON. This is structured logging:

{
  "level": "info",
  "event": "error",
  "code": "NOT_FOUND",
  "message": "Product not found",
  "statusCode": 404
}
{
  "level": "error",
  "event": "unhandled_error",
  "message": "Cannot read properties of undefined",
  "stack": "TypeError: ..."
}

Info-level logs are for expected errors (not found, validation failed). These happen all the time and are part of normal operation. Error-level logs are for bugs that need investigation. This distinction matters when you set up monitoring and alerting. You do not want to get paged every time someone requests a product that does not exist.

Exercises

Exercise 1: Implement the global error handler. Throw a NotFoundError from a service function. Verify the response has the correct format and status code.

Exercise 2: Cause a regular Error (not an AppError) to be thrown in a route. Verify the response says “An unexpected error occurred” (not the real message) and the real error appears in the logs.

Exercise 3: Compare returning notFound("Product") directly in a route vs throwing new NotFoundError("Product") from a service function. Both should produce the same response format.

Our error handler treats known and unknown errors differently, but we have not really explained what makes an error “known” vs “unknown.” That distinction is important enough to deserve its own lesson.

Why does the global error handler return a generic message for unknown errors?

← Custom error classes Operational vs programmer errors →

© 2026 hectoday. All rights reserved.