We've covered creating and throwing good errors, now let's talk about handling errors. The way you handle errors is important. You can end up writing a lot of code in different parts of your app if you're not careful. More code isn't always bad, but duplicated error handling can lead to your errors not being surfaced to where they're needed.
- Bubbling up in Express
- Bubbling up in AWS Lambda
- Don't fear the try/catch block
- Handling recoverable errors
In Express, error handlers can be bound at the Application and Route level. This allows you to write lots of error handling code in one place, and you can rely on your Express error handler to do things such as log the appropriate error detail, forward them on to a service like Splunk, or render a custom error page.
The following is not a great pattern, as Express has no chance to log the error to a central place. This means our logs could be missing information that's essential to debugging this error:
app.get('/', (request, response) => {
try {
exampleFunctionWhichThrowsAnError();
} catch (error) {
response.status(500).send(error.message);
}
});
The way you make sure errors are handled by the main Express error handler is by always passing errors on. The way you do this will depend on whether your route handler is async
or not.
For non-async
route handlers, any errors thrown will immediately be handled by Express:
// This is better because Express will get hold of the error, potentially
// log it, and render a page with a `500` status code
app.get('/', (request, response) => {
exampleFunctionWhichThrowsAnError();
});
This keeps things simple, and if you're throwing your own error in this route then you can do so with the regular throw
keyword:
throw new Error('Something went wrong');
For async
route handlers, it's very important that you either use a module like express-async-errors
(to avoid app crashes) or wrap your handler in an outer try/catch block.
This is because unhandled Promise exceptions will crash your app entirely, instead of just serving an error to the one user request which caused the error (see Express documentation on error handling).
Example of wrapping with an outer try/catch and using next
:
app.get('/', async (request, response, next) => {
try {
await exampleFunctionWhichThrowsAnError();
} catch (error) {
// With a wrapping try/catch block, it's important to
// pass the error onto the next route handler so that
// it can be logged and an appropriate error page can
// be rendered
next(error);
}
});
Example of using the express-async-errors
module (looks a lot like the sync example, right?):
// This should be in your app setup, the same place you
// `require` in Express and before you make any calls to
// `app.use` or `app.get` etc.
require('express-async-errors');
app.get('/', async (request, response, next) => {
await exampleFunctionWhichThrowsAnError();
});
This way of handling async errors will also be built-in in Express v5.
Note
The rest of the examples in this documentation assume that you're not including express-async-errors
. If you do use this library then you can simplify a lot when you're implementing in your own app.
The Express documentation covers writing centralised error handlers, but Reliability Kit provides some of it's own which can be used to log errors in a consistent and centralised way. It's generally better to rely on these than to write your own error handlers for your app.
In a Lambda-based application, a lot of the same principles apply as in Express. Regular Lambda functions don't have the concept of a next
function you can pass errors to, but you should ensure that even errors you don't expect are caught and handled at the top level:
module.exports.handler = async function handler() {
try {
// All your code goes here
} catch (error) {
// Here's your central error handling
}
};
In the top-level catch you could add some consistent error logging, send some metrics, and ensure that your team is immediately aware of any unexpected errors.
One of the tools we have to make sure thrown errors are specific is the try
/catch
block. In previous examples, we've wrapped all our code in a single try
/catch
block so that we can handle all errors consistently. If we want to be really specific about what went wrong in our code, then this is often not enough.
We have a couple of different ways that we update our try
/catch
to help us to handle errors and rethrow more specific ones. We can use either or both of these. The examples in this section use Express (because the majority of our apps use this) but the principals also apply to AWS Lambda or any other underlying framework.
In our final catch
we can inspect the error object we get and either send it to Express as-is or throw our own error if the original doesn't give us the detail we need, e.g. if a third-party library doesn't throw particularly useful errors:
// E.g. GET https://your-app/fruit/feijoa
app.get('/fruit/:name', async (request, response, next) => {
try {
// Add some basic validation of the param
assert.notStrictEqual(request.params.name, '');
// Make an API request using an example API client
const fruit = await fruitApiClient.getByName(request.params.name);
response.send(fruit);
} catch (error) {
next(error);
}
});
In this scenario we have the following possible errors which we know can make it into our final try
/catch
:
-
The
name
parameter is an empty string, throwing anAssertionError
-
The fruit API is down and the client throws an error with a
statusCode
property set to503
-
The fruit API does not have a fruit matching
request.params.name
and so the client throws an error with astatusCode
property set to404
Let's update our catch
block to handle these cases (Note: this example uses UpstreamServiceError
and UserInputError
from the @dotcom-reliability-kit/errors
package):
// E.g. GET https://your-app/fruit/feijoa
app.get('/fruit/:name', async (request, response, next) => {
try {
// [The code in the example above, excluded for brevity]
} catch (error) {
// If the name parameter is not set, let's throw a more
// friendly 400 error. We know that we're dealing with
// that error if we have a `AssertionError` or if
// `error.code` is "ERR_ASSERTION". We throw a 400 error
// in this case to indicate that the input was invalid
if (error.code === 'ERR_ASSERTION') {
return next(new UserInputError({
code: 'FRUIT_NAME_NOT_PROVIDED',
message: 'A fruit name was not provided'
}));
}
// If the fruit was not found, we throw a new 404 error
// with some added detail
if (error.statusCode === 404) {
return next(new UserInputError({
statusCode: 404,
code: 'FRUIT_NOT_FOUND',
message: `The fruit ${request.params.name} was not found in the fruit API`
}));
}
// If the fruit API was down, let's throw an error which
// better describes what's happened. UpstreamServiceError switches
// the status code to 502 because that matches what's happening –
// we received an invalid response from an upstream server.
// We can also indicate the system which caused the error
if (error.statusCode === 503) {
return next(new UpstreamServiceError({
code: 'FRUIT_API_FAILED',
message: 'The fruit API was not reachable',
relatesToSystems: ['fruit-api']
}));
}
// We default to passing on the error as-is, because
// this is probably a programmer error rather than
// operational and we want to let the app decide how
// to handle it later
next(error);
}
});
With this checking in place you can be confident that your error logging (and any dashboards powered by it) will display easy to understand errors and make debugging much easier. However, this checking is quite simplistic; what if we make multiple API requests, any of which can fail?
Please don't be scared of multiple try
/catch
blocks. It's a way we can be certain what the error we're throwing relates to, and it's very powerful when multiple try
/catch
are used within a wrapping block. Let's say we have this code, which interacts with multiple APIs before returning a response.
In this example we have two example APIs which have clients, a person
API, a fruit
API:
// E.g. GET https://your-app/people/rowanm/favourite-fruit
app.get('/people/:person/favourite-fruit', async (request, response, next) => {
try {
// First get the person details
const person = await personApiClient.getByName(request.params.person);
// Then get details about their favourite fruit
const fruit = await fruitApiClient.getByName(person.favouriteFruit);
response.send(fruit);
} catch (error) {
next(error);
}
});
In this scenario we have quite a few possible errors which can occur:
-
The person API is down and the client throws an error with a
statusCode
property set to503
-
The person API does not have a person matching
request.params.person
and so the client throws an error with astatusCode
property set to404
-
The fruit API is down and the client throws an error with a
statusCode
property set to503
-
The fruit API does not have a fruit matching
person.favouriteFruit
and so the client throws an error with astatusCode
property set to404
Now in our final catch
block, we can't tell which API is down if we recieve an error
with a statusCode
of 503
. We can handle this by nesting our try
/catch
blocks and throwing appropriate errors. For brevity, we've handled both 404
errors in one place in this example:
// E.g. GET https://your-app/people/rowanm/favourite-fruit
app.get('/people/:person/favourite-fruit', async (request, response, next) => {
try {
// First get the person details
let person;
try {
person = await personApiClient.getByName(request.params.person);
} catch (error) {
// We throw here rather than passing to `next` because it allows
// us to handle all of that in one place – the final try/catch
if (error.statusCode === 503) {
throw new UpstreamServiceError({
code: 'PERSON_API_FAILED',
message: 'The person API was not reachable',
relatesToSystems: ['person-api']
});
}
// We still want to throw if we have a different error
// from this service
throw error;
}
// Then get details about their favourite fruit
try {
const fruit = await fruitApiClient.getByName(person.favouriteFruit);
response.send(fruit);
} catch (error) {
// We throw here rather than passing to `next` because it allows
// us to handle all of that in one place – the final try/catch
if (error.statusCode === 503) {
throw new UpstreamServiceError({
code: 'FRUIT_API_FAILED',
message: 'The fruit API was not reachable',
relatesToSystems: ['fruit-api']
});
}
// We still want to throw if we have a different error
// from this service
throw error;
}
} catch (error) {
// Handle the 404 case
if (error.statusCode === 404) {
return next(new HttpError({
statusCode: 404,
code: 'FAVOURITE_FRUIT_NOT_FOUND',
message: `A favourite fruit cannot be found for ${request.params.person}`
}));
}
// If we get here, we could have either an unknown programmer error or
// one of our specially thrown 502 errors
next(error);
}
});
With this checking and multiple try
/catch
blocks in place you can be confident about exactly which part of your app is failing. If an API is down then you get a message which specifically outlines which. Your tooling/reporting can also confidently say X% of requests fail because this specific API is down a lot and you can prioritise some work to address this.
Sometimes an error in your code is recoverable, and a page can continue to be rendered even if something failed. We call these recoverable errors. Even when an error is recoverable, it's still useful to have logs to help us understand how frequently certain parts of a website are missing.
An error we recover from might look something like this (back to using our Express fruit API example). This is a poor example because we lose a lot of error information by logging very little:
// E.g. GET https://your-app/fruit/feijoa
app.get('/fruit/:name', async (request, response, next) => {
try {
// Make an API request using an example API client
const fruit = await fruitApiClient.getByName(request.params.name);
// Add stock level to the fruit. This is a recoverable error because
// we still want to send fruit information to the user if the stock
// level cannot be retrieved
try {
fruit.stockLevel = await fruitApiClient.getStockLevel(fruit.id);
} catch (error) {
console.warn('Fruit stock information could not be retrieved');
fruit.stockLevel = null;
}
response.send(fruit);
} catch (error) {
next(error);
}
});
In our catch
block for the fruit stock level, we're ignoring the error entirely and logging a warning. We can still make some improvements here and make our reporting/debugging much easier. Let's use the serializeError
function from the @dotcom-reliability-kit/serialize-error
package):
// E.g. GET https://your-app/fruit/feijoa
app.get('/fruit/:name', async (request, response, next) => {
try {
const fruit = await fruitApiClient.getByName(request.params.name);
try {
fruit.stockLevel = await fruitApiClient.getStockLevel(fruit.id);
} catch (error) {
// We log as much error detail as possible, and in the same format
// as the errors we throw so that we have some consistency across
// our logging
console.warn({
event: 'RECOVERABLE_ERROR',
error: serializeError(new OperationalError({
code: 'FRUIT_STOCK_LEVEL_FAILED',
message: `The Fruit API did not return a stock level for ID ${fruit.id}`,
relatesToSystems: ['fruit-api'],
cause: error
}))
});
fruit.stockLevel = null;
}
response.send(fruit);
} catch (error) {
next(error);
}
});
We've started to introduce some logging of our errors and consistent serialization. We'll cover more about logging errors in the next section.
← Previous | Next → |
---|---|
Throwing errors | Logging errors |