Back to main page Traveling Coderman

Node.js Microservice Architecture Axios HTTP error handling in Express.js

HTTP communication with other services is error-prone.

A status code 2xx means success and indicates that we can proceed with the regular happy path.

A status code 4xx indicates an error on we made with the request. Some of these errors indicate a bug in our service. An authentication error 401 or 403 should not occur in our outgoing authentication since we always send a valid JWT. Other errors might be regular cases we want to handle in the business logic. For example, a not found error 404 indicates that the resource we are looking for does not exist.

A status code 5xx indicates an error at the other service or in the network between our and the other service. Some of these can be minimized with concepts like circuit breakers between the services. The remaining errors 5xx should result in an internal server error 500 of our service and an error log line. The internal server error returned to the user should contain minimal detail for a support engineer to track down the cause. The error log line should be more detailed and notify the team.

Beside of error status codes, there can also be timeouts. If Axios does not get a response in time, then also these errors should result in an internal server error and an error log line.

In this post, we implement this behaviour.

The Axios error 🔗

When we do an HTTP request with Axios, then by default every non-2xx response results in an error AxiosError. We do not catch it on purpose: There is nothing reasonable we could do about an error here.

import axios from "axios";
import { config } from "../../configuration/config";

export function sendNotification(message: string): Promise<void> {
return axios.post(`${config.http.servicesUrl}/notify/notifications`, {
message,
});
}

If we knew that there are error responses that we can react to, then we can use the option validateStatus. All accepted status codes don't lead to an error but to a regular response instead. However, note that this requires you to inspect the status code to know how to parse the response body.

import axios from "axios";
import { config } from "../../configuration/config";

export function sendNotification(message: string): Promise<void> {
return axios.post(
`${config.http.servicesUrl}/notify/notifications`,
{
message,
},
{
validateStatus: (status) =>
(status >= 200 && status < 300) || status == 404,
}
);
}

Handling all Axios errors 🔗

For all unhandled errors, we create an Express.js error handler handleHttpError.

import express from "express";
import { todoRoute } from "./controllers/todos/todo.router";
import { handleHttpError } from "./http/http-error-handler";
// ...

const app = express();

// ...

app.use("/todos", todoRoute);

app.use(handleHttpError);
// ...

export default app;

Express.js calls this handler for all errors that have been passed with next(error) in a controller. All our controllers catch exceptions and pass them to next.

import { NextFunction, Request, Response } from "express";
// ...

export async function deleteTodoController(
request: Request,
response: Response,
next: NextFunction
): Promise<void> {
try {
// ...
} catch (error) {
next(error);
}
}

The error handler handleHttpError uses a function isAxiosError to verify that the error is of type AxiosError.

import { AxiosError } from "axios";

function isAxiosError<T>(error: Error | AxiosError<T>): error is AxiosError<T> {
return "isAxiosError" in error && error.isAxiosError;
}

If the error is not an AxiosError, it is passed to the next error handler.

Otherwise, we write a detailed error log line with the Pino logger. Since we have the object request, we use the logger request.log. This logger automatically attaches the request id to the log line and allows us to filter the log lines for the associated request.

import { AxiosError } from "axios";
import { NextFunction, Request, Response } from "express";

export function handleHttpError(
error: Error,
request: Request,
response: Response,
next: NextFunction
): void {
if (isAxiosError(error)) {
request.log.error(
{
method: error.config.method?.toUpperCase(),
url: error.config.url,
headers: error.config.headers,
params: error.config.params,
message: error.message,
response: {
status: error.response?.status,
data: error.response?.data,
},
stack: error.stack,
},
"Error Response"
);
response.status(500).json({
message: `Failed request`,
method: error.config.method?.toUpperCase(),
url: error.config.url,
});
} else {
next(error);
}
}

Also, we send an internal server error 500 to the user. We include the method and url. This makes it easier to find the log line and possibly already allows the reproduction of the issue.

Testing the handler 🔗

We add a test to verify the log output and response. Another test verifies that indeed the next error handler is called if the request did not result in an AxiosError.

import { AxiosError } from "axios";
import { mockControllerInputs } from "../test.functions";
import { handleHttpError } from "./http-error-handler";

describe("the HTTP error handler", () => {
it("should log the HTTP error and send a 500", () => {
const { request, response, next } = mockControllerInputs({
headers: {},
});
const error = new AxiosError(
"message",
"code",
{
method: "get",
url: "https://some.url/",
headers: {
a: "b",
},
params: {
c: "d",
},
},
{},
{
status: 200,
statusText: "statusText",
data: {},
headers: {},
config: {},
}
);
handleHttpError(error, request, response, next);
expect(request.log.error).toHaveBeenCalledWith(
{
method: "GET",
url: "https://some.url/",
headers: {
a: "b",
},
params: {
c: "d",
},
message: "message",
response: {
status: 200,
data: {},
},
stack: error.stack,
},
"Error Response"
);
expect(next).not.toHaveBeenCalled();
});

it("should skip different errors", () => {
const { request, response, next } = mockControllerInputs({
headers: {},
});
const error = new Error("No HTTP error");
handleHttpError(error, request, response, next);
expect(request.log.error).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledWith(error);
});
});

Conclusion 🔗

We added an Express.js error handler. It logs errors in the communication with other services. Also, it responds with an internal server error 500 to users.

Become a GitHub sponsor to access the code of this post as a GitHub repo.