DEV Community

Cover image for A Practical Approach to Error Handling in Express.js
Juan Oliú
Juan Oliú

Posted on

A Practical Approach to Error Handling in Express.js

Motivation

In the intricate world of software development, errors are inevitable. They emerge from various sources: unforeseen edge cases, unexpected user inputs, or even overlooked developer mistakes. While it's impossible to predict every potential error, how we handle these errors can make a significant difference in the user experience and the maintainability of our projects.

In my experience with Node.js and Express, I've noticed that projects often face challenges in two main areas: pinpointing all possible error scenarios for a feature and handling those exceptions. While the skill level of developers is a factor, it's common in multiple projects to encounter similar error scenarios. Identifying these recurring patterns can help in creating standardized methods for error management.

In this article, I'll share my approach to error management, inspired by my experiences and the practices I've adopted in recent Node.js projects with Express. Furthermore, I'll delve into how GraphQL, particularly Apollo GraphQL, has influenced my perspective on error handling in RESTful applications.

GraphQL inspiration

From now on I have to say that I am a big fan of Apollo GraphQL, I love how the paradigm defines an API in the form of a resource graph, the way in which that conditions the architecture of the server and among other things I really like the way on how it handles errors. Let's stop at this last point and discuss this before continuing with error handling in Express.

In GraphQL, and therefore in Apollo, the philosophy of error handling differs from the traditional approach based on HTTP status messages. There are some reasons why Apollo and other GraphQL servers do not primarily rely on HTTP status codes to convey details about errors

  1. More Detailed Responses: GraphQL allows a response to include both data and errors. That is, part of your query could fail while other parts could succeed. In an HTTP-based approach, you would have to choose a single status code to represent the entire response, which wouldn't capture the mixed nature of the GraphQL response.

  2. Nature of GraphQL Operations: A single GraphQL request can include multiple operations affecting different areas of your schema. If one of these operations fails and another succeeds, what HTTP status code should you return? GraphQL solves this problem by providing field-level errors.

  3. Granularity: With GraphQL, you can specify errors at the field level, allowing you to provide very specific information about which part of the query failed and why. This granularity would be lost if relying solely on general HTTP status codes.

  4. Client Interoperability: GraphQL clients, like Apollo Client, expect to handle errors in the standardized GraphQL format and not through HTTP status codes. This allows clients to handle errors in a more uniform and detailed manner.

  5. Flexibility and Customization: While GraphQL has a standardized format for errors, it is also flexible. You can add additional fields to your errors (such as "code" or "context") to convey more information to clients based on the needs of your application.

First of all, let's not lose sight of the fact that GraphQL and Rest API obey different paradigms, which makes it unnatural or rather a very bad practice to model errors in the same way. Even so, I have to say that of all these advantages there are some very interesting ones that I felt I could rescue for a server with a Rest API.

On one hand, I really like the idea of ​​loading data along with the error message, perhaps not for the response itself but to send to an error tracking platform in the event of some types of exceptions, we will talk about this later.

On the other hand, I find the interoperability that we could generate with the client interesting by defining errors in a more personalized way following certain standards. That is, we should not use the http status so much but rather a more descriptive error code and at the same time be able to define something that allows us to group them into more abstract categories.

Pay attention to the built-in error codes defined by Apollo server:

Built-in error codes

CODE DESCRIPTION
GRAPHQL_PARSE_FAILED The GraphQL operation string contains a syntax error.
GRAPHQL_VALIDATION_FAILED The GraphQL operation is not valid against the server's schema.
BAD_USER_INPUT The GraphQL operation includes an invalid value for a field argument.
PERSISTED_QUERY_NOT_FOUND A client sent the hash of a query string to execute via automatic persisted queries, but the query was not in the APQ cache.
PERSISTED_QUERY_NOT_SUPPORTED A client sent the hash of a query string to execute via automatic persisted queries, but the server has disabled APQ.
OPERATION_RESOLUTION_FAILURE The request was parsed successfully and is valid against the server's schema, but the server couldn't resolve which operation to run.
This occurs when a request containing multiple named operations doesn't specify which operation to run (i.e.,operationName), or if the named operation isn't included in the request.
BAD_REQUEST An error occurred before your server could attempt to parse the given GraphQL operation.
INTERNAL_SERVER_ERROR An unspecified error occurred.
When Apollo Server formats an error in a response, it sets the code extension to this value if no other code is set.

Let's leave aside those that apply to GraphQL concepts and emphasize in the others. Take a look at them BAD_USER_INPUT, BAD_REQUEST and INTERNAL_SERVER_ERROR, this kind of level of abstraction is something we can use for categorizing errors in a Rest API server.

Let's stick with this idea. In the next section, we'll discuss the different types of errors we can generate to replicate a somewhat similar behavior.

Custom Error Classes

Custom error classes can help standardize error responses and provide better context for debugging and the the frontend present exceptions to the final user.

Let's start with the following class:

HttpError Class

The HttpError class serves as the base class for your custom error hierarchy. It encapsulates key error attributes and behaviors.

class HttpError extends Error {
  constructor(status, name, message, extra) {
    super(message);
    this.status = status;
    this.name = name;
    this.extra = extra;
  }
}
Enter fullscreen mode Exit fullscreen mode

Let's define a set of classes to group different types of errors based on their nature. Among other things, they will all share a code as a common property to identify them. The code should be descriptive enough for a developer to understand what it refers to; by convention, we will write it in UPPER_CASE. Note that I mentioned it should be descriptive enough for a developer, not the end user. Please never use the error message from a server response as a message for the end user. The right approach would be to process it and generate one that better fits the language and context an average user would understand. Let's never lose sight of the fact that developers and users don't speak, and shouldn't speak, the same language; we operate in different contexts. Something similar happens between frontend and backend.

Returning to our custom error classes, these are the ones we will initially use. Please take this as a suggestion and not as a golden rule.

Custom Error Classes

These classes extend the HttpError class to represent specific error types, providing predefined error codes, names, and messages.

class UserInputError extends HttpError {
  constructor(message, extra) {
    super(400, "Bad Request", message, extra);
  }
}

class AuthenticationError extends HttpError {
  constructor(message, extra) {
    super(401, "Unauthorized", message, extra);
  }
}

class ForbiddenError extends HttpError {
  constructor(message, extra) {
    super(403, "Forbidden", message, extra);
  }
}

class NotFoundError extends HttpError {
  constructor(message, extra) {
    super(404, "Not Found", message, extra);
  }
}

class InternalServerError extends HttpError {
  constructor(message, extra) {
    super(500, "Internal Server Error", message, extra);
  }
}
Enter fullscreen mode Exit fullscreen mode

Descriptions:

  • UserInputError: This error class is used to indicate that the user's input or request is invalid. It corresponds to the HTTP status code 400 (Bad Request). It's typically triggered when the user submits data that doesn't meet the required criteria.

  • AuthenticationError: This error class represents authentication failures. It's used when a user's credentials are invalid or missing, and corresponds to the HTTP status code 401 (Unauthorized).

  • ForbiddenError: This error class signifies that the user's request is valid but they don't have the necessary permissions to access the requested resource. It maps to the HTTP status code 403 (Forbidden).

  • NotFoundError: When the requested resource is not found on the server, the NotFoundError class is employed. This corresponds to the HTTP status code 404 (Not Found).

  • InternalServerError: This error class is a catch-all for unexpected server errors that occur during the request processing. It corresponds to the HTTP status code 500 (Internal Server Error).

As you can see, this covers the more generic error cases and leads us to a simple way of cataloging the errors.

Naturally, one might wonder, now that we have our error classes, how do we use them? Fortunately, we can make use of Express middlewares for this purpose.

Error handling within a middleware

We will define our own middleware to capture errors and present them appropriately in the response, as well as trigger the necessary actions should the situation warrant it.

Before doing so, let us remember that we consider it pertinent to make a brief mention of what Express middlewares are. I have to assume that many of the readers might not know them, and for those who do, a review never hurts.

Express middleware functions

Middleware functions are functions that have access to the request object (req), the response object (res), and the next middleware function in the application’s request-response cycle. The next middleware function is commonly denoted by a variable named next.

Middleware functions can perform the following tasks:

  1. Execute any code. This allows you to add any functionality or processing you want to occur during the request-response cycle.
  2. Make changes to the request and the response objects. This can be useful for things like parsing request bodies, setting response headers, etc.
  3. End the request-response cycle. If a middleware function does not call next(), the cycle will be terminated, and the response will be sent to the client.
  4. Call the next middleware function in the stack. If a middleware function does not end the request-response cycle, it must call next() to pass control to the next middleware function. Otherwise, the request will be left hanging.

Types of Middleware:

Application-level middleware: This binds middleware to the app object using app.use() and app.METHOD(), where METHOD is the HTTP method.

   app.use(function (req, res, next) {
     console.log("Time:", Date.now());
     next();
   });
Enter fullscreen mode Exit fullscreen mode

Router-level middleware: This works in the same way as application-level middleware, but it's bound to an instance of express.Router().

   const router = express.Router();
   router.use(function (req, res, next) {
     console.log("Time:", Date.now());
     next();
   });
Enter fullscreen mode Exit fullscreen mode

Error-handling middleware: This is used to handle errors in Express apps. Error-handling middleware always takes four arguments. You must provide four arguments to identify it as an error-handling middleware function.

   app.use(function (err, req, res, next) {
     console.error(err.stack);
     res.status(500).send("Something broke!");
   });
Enter fullscreen mode Exit fullscreen mode

Built-in middleware: Express has a few built-in middleware functions, such as express.static to serve static files.

   app.use(express.static("public"));
Enter fullscreen mode Exit fullscreen mode

Third-party middleware: There are many third-party middleware modules available, like body-parser for parsing request bodies, morgan for logging, cors for handling CORS, etc.

   const bodyParser = require("body-parser");
   app.use(bodyParser.json());
Enter fullscreen mode Exit fullscreen mode

Using Middleware:

To use a middleware, you typically call app.use(). The order in which middleware is defined matters, as they are executed sequentially. For example, if you place a logging middleware above a route, the logging will occur before the route's logic is executed. If you place it below, the logging will occur after.

Example:

Here's a simple example to illustrate middleware in action:

const express = require("express");
const app = express();

// Middleware 1: Logging
app.use((req, res, next) => {
  console.log("Middleware 1: Logging request details");
  next();
});

// Middleware 2: Add a property to the request
app.use((req, res, next) => {
  req.sampleProperty = "Hello from Middleware 2";
  next();
});

// Route handler
app.get("/", (req, res) => {
  res.send(req.sampleProperty); // This will send 'Hello from Middleware 2'
});

app.listen(3000, () => {
  console.log("Server started on port 3000");
});
Enter fullscreen mode Exit fullscreen mode

In this example, when a request is made to the root route (/), both middleware functions are executed in order, followed by the route handler.

In summary, middleware in Express.js provides a powerful way to add functionality and handle various aspects of the request-response cycle. By chaining middleware functions, you can build complex and efficient web applications.

Let us pay special attention to the third case because it is the one that we will define below.

Custom Middlewares for Error Handling

We will use 2. The first will be responsible for triggering special actions given some error cases, such as in the event of an internal server error we would like to log it and report it to an error-tracking platform. At the same time, it will also be in charge of wrapping any error that it intercepts whose class is not part of any of the ones we defined.

The second middleware is only responsible for generating the request message from the error instance. Here it is important to have defined our "abstract class" HttpError.

errorConverter Middleware

const errorConverter = (
  error: Error | HttpError | ValidateError,
  _req: Request,
  _res: Response,
  next: NextFunction
) => {
  if (error instanceof InternalServerError) {
    logError(error.name, error.extra);
  }
  if (error instanceof ForbiddenError) {
    if (error.extra) logError(error.name, error.extra);
  }
  if (error instanceof HttpError) {
    if (error.extra) logError(error.name, error.extra);
    next(error);
  } else if (error instanceof ValidateError) {
    next(new UserInputError(formatFields(error.fields)));
  } else {
    next(new InternalServerError(error.message));
  }
};
Enter fullscreen mode Exit fullscreen mode

errorHandler Middleware

const errorHandler = (
  error: HttpError,
  _req: Request,
  res: Response,
  _next: NextFunction
) => {
  res.status(error.status).send({
    status: error.status,
    message: error.message,
    name: error.name,
  });
};
Enter fullscreen mode Exit fullscreen mode

Logger module

As you may have noticed within the errorConverter module, I used a logError function. Normally I like defining in my projects a module in charge of centralizing all log functions in charge of reporting all the exceptions or warns as well as error-tracking platforms reports.

Of course it is not something you have to do but it is something I strongly recommend. I'll leave an example not so different from the ones I have used lately that has already integration with Sentry:

import * as Sentry from "@sentry/node";
import { Event } from "@sentry/types";

import { environment } from "../common/environment";

export type Extra = Record<string, unknown>;

enum Severity {
  Debug = "debug",
  Error = "error",
  Fatal = "fatal",
  Info = "info",
  Log = "log",
  Warning = "warning",
}

const logToSentry = (level: Severity, message: string, extra?: Extra) => {
  if (environment.isDevelopment) {
    const optionalArgs = extra ? [extra] : [];
    if (level === Severity.Error) {
      console.error(message, ...optionalArgs);
      return;
    }
    if (level === Severity.Warning) {
      console.warn(message, ...optionalArgs);
      return;
    }

    console.log(message, ...optionalArgs);
    return;
  }

  const event: Partial<Event> = {
    extra,
    level,
    message,
  };
  try {
    Sentry.captureEvent(event);
  } catch (error) {
    console.error("Could not log to Sentry", message, extra, error);
  }
};

export const logFatal = (title: string, details?: Extra) => {
  logToSentry(Severity.Fatal, title, details);
};

export const logError = (title: string, details?: Extra) => {
  logToSentry(Severity.Error, title, details);
};

export const logWarning = (title: string, details?: Extra) => {
  logToSentry(Severity.Warning, title, details);
};

export const logInfo = (title: string, details?: Extra) => {
  logToSentry(Severity.Info, title, details);
};
Enter fullscreen mode Exit fullscreen mode

Conclusion

Error handling is an indispensable aspect of building robust and user-friendly applications. In the realm of Node.js with Express, it's not just about catching errors, but also about understanding, categorizing, and appropriately responding to them. Drawing inspiration from GraphQL's detailed and granular error handling, we can adopt a more structured approach in our RESTful APIs. By defining custom error classes, we not only standardize error responses but also provide a clearer context for debugging and user feedback.

Furthermore, the use of Express middlewares offers a seamless way to intercept and process these errors. This ensures that our application remains resilient, even when faced with unexpected issues. Integrating logging mechanisms, such as Sentry, further enhances our ability to monitor and address potential pitfalls in real-time.

In essence, a well-thought-out error handling strategy is akin to a safety net for our applications. It ensures that when things go awry, the impact is minimal, the feedback is clear, and the path to resolution is well-defined. As developers, it's our duty to ensure that our applications not only function but also fail gracefully. Through the techniques discussed in this post, we can take a significant step towards achieving that goal.

Special mention

I want to give a big shoutout to @joaguirrem. The ideas behind this system started when we worked on a project together about a year and a half ago. If anyone deserves a pat on the back for this, it's him. Building software is a team effort, and it's always great to work with talented folks like Joaquin. They make the work better and the journey more enjoyable.

Top comments (0)