8 minute read

The baseline project

This post assumes you are following along with the baseline project tutorial, but the concepts apply to any Typescript project.

Express error handling

In the previous post, we implemented listeners to the uncaughtException and unhandledRejection events in node to ensure any unexpected errors crash the application. However, our job is not done. The express framework, by default, handles any errors that occur within a route and sends an error response to the HTTP client. Even if these errors are unexpected errors that should cause our program to terminate, they will be caught before they can bubble up to our handle function.

In addition, the default error handler responds with an HTML page, which isn’t ideal. For our API, it would be better if we could send some nicely structured, predictable JSON data instead.

To make sure unexpected errors within routes are handled properly, we need to create our own custom error middleware to replace the default error handler in express.

Not found middleware

We are actually going to write two separate error handling middlewares. The first will be a catch-all middleware that will respond with a 404 status and a message when a client tries to access a route that doesn’t exist.

When a request is received by express, it will try to execute any applicable middleware functions and match any routes in order of their declaration in application. We can exploit this behavior to create a 404 handler by creating a middleware at the very end of our app declaration that applies to all routes.

We are going to use a convenient package named @hapi/boom to generate predictable error messages without having to repetitively declare them in our code. Go ahead and install the package:

$ yarn add @hapi/boom

Now, in our src/util/error.ts file, let’s import the @hapi/boom package and use it to create an express middleware function:

// src/util/error.ts

import boom from "@hapi/boom";
import type { NextFunction, Request, Response } from "express";

// existing code ...

export const notFoundMiddleware = (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  next(boom.notFound("The requested resource does not exist."));
};

An express middleware function is simply a function that excepts a Request object, a Response object, and the NextFunction function. Upon calling the NextFunction, control is passed to the next middleware function or route in the chain. If an object is passed to the NextFunction, the value is passed to the error handler.

A note on the @hapi/boom library: its main function is to generate custom errors decorated with extra properties relating to HTTP status codes. In this case we use it to generate a 404 error with a custom message.

General error middleware

In express, error handling middleware is defined in nearly the same way as normal middleware, but with an extra Error object as the first parameter.

Let’s create our general error middleware at the end of the src/util/error.ts file:

// src/util/error.ts

// existing code ...

export const errorMiddleware = (
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
) => {
  const {
    output: { payload: error, statusCode },
  } = boom.boomify(err);

  res.status(statusCode).json({ error });
  if (statusCode >= 500) {
    handle(err);
  }
};

The boomify function can take an Error and determine whether it is already an error decorated by boom. If not, it decorates it with a 500 status code.

In the first statement, we pass the err parameter to the boomify function. Using object destructuring, we assign the nested values error and statusCode to variables.

Next, we send a response with that statusCode to the client, along with error as a JSON response.

Finally, if the statusCode is 500 or above, we initiate our handle function that terminates the server.

Your final src/util/error.ts file should look like this:

// src/util/error.ts

import boom from "@hapi/boom";
import type { NextFunction, Request, Response } from "express";
import pino from "pino";

import { logger } from "./logger";

export const handle = pino.final(logger, (err, finalLogger) => {
  finalLogger.fatal(err);
  process.exitCode = 1;
  process.kill(process.pid, "SIGTERM");
});

export const notFoundMiddleware = (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  next(boom.notFound("The requested resource does not exist."));
};

export const errorMiddleware = (
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
) => {
  const {
    output: { payload: error, statusCode },
  } = boom.boomify(err);

  res.status(statusCode).json({ error });
  if (statusCode >= 500) {
    handle(err);
  }
};

Using the middleware

In our express app definition, we need to apply our middleware functions in a specific order. The notFoundMiddleware function should be applied after all other middleware and route functions, and the errorMiddleware should be applied immediately after that, and it should be the final middleware applied:

// src/app.ts

import { errorMiddleware, notFoundMiddleware } from "./util/error";

// existing code ...

app.use([notFoundMiddleware, errorMiddleware]);

export { app };

Passing the middleware functions in an ordered array is equivalent to calling:

app.use(notFoundMiddleware);
app.use(errorMiddleware);

Your final src/app.ts file should look like this:

// src/app.ts

import express from "express";
import pinoHttp from "pino-http";

import { errorMiddleware, notFoundMiddleware } from "./util/error";
import { logger } from "./util/logger";

const app = express();

app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(pinoHttp({ logger }));

app.get("/api/health", (req, res) => res.send({ message: "OK" }));

app.use([notFoundMiddleware, errorMiddleware]);

export { app };

Testing our middleware

Let’s temporarily create some test routes in src/app.ts to see if our middleware behaves as expected:

// src.app.ts

import boom from "@hapi/boom";

// existing code ...

app.get("/api/bad-request", (req, res) => {
  throw boom.badRequest("Test bad request");
});

app.get("/api/unsafe-error", (req, res) => {
  throw new Error("Test unsafe error");
});

// existing error middleware and app export ...

Start the server using the yarn scripts we set up in a previous post:

$ yarn dev

First, let’s test our 404 middleware for routes that don’t exist. Using your browser, or an API testing tool like Postman, make a GET request to a non-existent route:

GET http://localhost:5000/api/nonexistent
{
  "error": {
    "statusCode": 404,
    "error": "Not Found",
    "message": "The requested resource does not exist."
  }
}

Next, the /api/bad-request route, which throws a decorated boom error:

GET http://localhost:5000/api/bad-request
{
  "error": {
    "statusCode": 400,
    "error": "Bad Request",
    "message": "Test bad request"
  }
}

Finally, the api/unsafe-error route, which throws an un-decorated, unsafe error:

GET http://localhost:5000/api/unsafe-error
{
  "error": {
    "statusCode": 500,
    "error": "Internal Server Error",
    "message": "An internal server error occurred"
  }
}

Checking the console, the app should have logged a FATAL error and terminated gracefully, as expected:

[1621663649232] FATAL (51576 on Jons-MacBook-Pro.local): Test unsafe error
    Error: Test unsafe error
        ... [STACK TRACE]
[1621663649238] INFO (51576 on Jons-MacBook-Pro.local): request errored
    	... [REQUEST METADATA]
[1621663649240] INFO (51576 on Jons-MacBook-Pro.local): SIGTERM received, closing gracefully ...

Go ahead and remove the test routes from src/app.ts.

Why this strategy?

We can separate the errors that might occur during the execution of our program into two categories:

  • Operational (safe) errors

Operational errors are expected errors that we handle within our code. For example, we might validate user input and determine that the input they provided is invalid (e.g., a string was provided when we expected a number). When operational errors occur, we can safely send stop executing our logic and send an error message to the user. We don’t need to terminate the process because we expected the error to happen, and handled it safely.

In the context of our application, this means using the boom library to generate an appropriate HTTP error response. Throwing this error with our error middleware will provide a user-friendly response while allowing our program to continue serving other clients.

  • Programmer (unsafe) errors

Programmer errors are unexpected errors that occur due to logical failures in your code. These errors are not safe and may leave the program in an and undefined state.

From the official Node.js documentation:

By the very nature of how throw works in JavaScript, there is almost never any way to safely “pick up where it left off”, without leaking references, or creating some other sort of undefined brittle state.

The safest way to respond to a thrown error is to shut down the process. Of course, in a normal web server, there may be many open connections, and it is not reasonable to abruptly shut those down because an error was triggered by someone else.

The better approach is to send an error response to the request that triggered the error, while letting the others finish in their normal time, and stop listening for new requests in that worker.

Whenever an error that has not been explicitly handled occurs in our application, our error middleware sends an error message to the client and passes control to our handle function, which gracefully terminates the program.

A note on promises

As we saw when testing our error middleware, any errors we throw in synchronous code are properly handled. However, express does not automatically forward promise rejections in asynchronous code to the error middleware. These promise rejections will be caught by our unhandledRejection listener and crash the server, even if they are operational errors.

Essentially, we need to ensure that all promise rejections in express route controllers are handled and forwarded to our error middleware. We have two options for doing this:

Option 1: Manually handle any promise rejections in route controllers

We can do this by remembering to adopt one of the following patterns in all of our route controllers that deal with asynchronous code:

// with async/await
const route = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const res = await myAsyncFunction();
  } catch (err) {
    next(err);
  }
};

// with promise chaining
const route = (req: Request, res: Response, next: NextFunction) => {
  myAsyncFunction()
    .then()
    .catch((err) => {
      next(err);
    });
};

The downside of this approach is that you may forget to implement these patterns in some of your route controllers, and cause your server to crash on operational promise rejections.

Option 2: Use the express-async-errors library

The express-async-errors library modifies the behavior of express so that promise rejections are automatically forwarded to the error handling middleware. This is the approach I am going to use for the baseline project.

Adding express-async-errors

Go ahead and install the package with Yarn:

$ yarn add express-async-errors

Import the library at the start of your app definition code in src/app.ts:

// src/app.ts

import "express-async-errors";

// existing code ...

That’s it! Promise rejections will be forwarded to your error middleware as expected.

Commit

Go ahead and stage your changes:

$ git add .

And commit them to source control:

$ git commit