So you have an error?
You've come to the right place.
Prelude
Before we begin, lets define two terms: route handler, and library code.
Route handler: This is code which knows about the server, the request, the response, etc.
E.g. in our code we define our server routes something like this (spread out over multiple files):
1 subscriptionRequestHandler = function (server) {
2 return {
3 createSubscription: async function (req, res, next) {
4 // ...
5 }
6 }
7 }
8
9 const subscriptionRequestHandler = subscriptionRequestHandler(server)
10
11 server.post("/subscription", subscriptionRequestHandler.createSubscription)
In this example, `createSubscription` is a route handler. It is a block of code that knows it is in a web server, and it has access to the `server` object, the request (`req`) and response (`res`), and in our case, the `restify` and `next` objects.
The route handler is going to be responsible for all the server tasks like sending a response back to the client. If it imports any code, that code does not need to know its part of a server.
For instance, consider the example of an imported module which validates strings. We are using it to validate and make-safe incoming strings:
createSubscription: async function (req, res, next) {
const username = myStringValidator(req.params.username)
/...
}
In this case, myStringValidator should not know anything about the server, request, or response. Rather, it is...
Library code.
For example, myStringValidator might throw an error if it encounters invalid data, but it absolutely should not be throwing that error back to the client, deciding on the response status code, or anything like that. To put it simply, the request handler should not pass `server`, `req`, `res`, `restify` or `next` into any code it calls.
Where do We Find Errors?
There are a few ways we might encounter errors:
1. We have a specific desire to throw an error in certain cases, e.g. we were passed invalid data:
a. In a route handler:
createSubscription: async function (req, res, next) {
if (!userProfileComplete(req.userData)) {
return next(new restify_errors.MissingParameterError("Subscription creation requires a complete profile!"))
}
//...
b. In library code:
myStringValidator(inputString) {
if (typeof inputString !== "string") throw new Error(`input ${inputString} to myStringValidator is not a string!`)
//...
}
2. We have no desire to throw an error, but we are prepared to catch it if we have to:
a: In a route handler:
try {
await libSubscription.createSubscription(user, subscriptionData)
} catch (err) {
const creationError = new restify_errors.InternalServerError(err, "Failed to create subscription.")
server.logger.warn("Subscription creation failed.", creationError)
return next(creationError)
}
b: In library code:
try {
await talkToMyDatabase() // might throw an Error!
} catch (err) {
throw new Error(
"My library function failed while trying to talk to the database",
{cause: err}
)
}
3. An error is thrown (or Promise is rejected) in a way that is unexpected and we do not catch it.
console.logger("I'm helping!") // we thought this object had this method but it does not
What do we do with errors?
We want to think about the following things when we are deciding how to handle errors:
- What information is attached to the error?
- Is the error logged?
- Does it cause a crash? (i.e. will the server process exit?)
- What does the client receive?
- ~~What is the user shown?~~ (this is a frontend problem - but we should be aware of it when thinking about #4)
Handling errors in library code:
Library code does not log, send things to clients, or cause crashes.
Errors from library code can of course cause crashes if the route handler does not catch them, but they cannot be "solely responsible" for crashes.
As such, there is only one thing we need to think about when handling errors in library code, and that is "What information is attached to the error?". We are going to simply `throw` an error at the end of the day, we just want it to be as rich and useful as possible.
A good way to have rich errors is to use `Error` objects. If you are catching another `Error`, like in example 2b. above, you should "re-throw" the error by wrapping it in a new `Error` and defining the `cause`. In this way, Errors can chain, and you can add information about all of the contexts throughout the call stack in which it was thrown. Otherwise, just throw a new `Error` with a descriptive message.
The other thing to keep in mind when throwing errors is that you should include any relevant data, if you can. For example, don't do this:
if (mode === "forward") {
// ...
} else if (mode === "reverse") {
// ...
} else if (mode === "idle") {
// ...
} else {
throw new Error("Invalid mode!")
}
Instead, do this:
if (mode === "forward") {
// ...
} else if (mode === "reverse") {
// ...
} else if (mode === "idle") {
// ...
} else {
throw new Error(`Mode ${mode} is not valid!`)
}
It's much better.
Whoever calls this code is still responsible for catching your error, but your rich error will make them happy if they do.
Note: `Error.cause` is a new addition to a late version of Node 16. Prior to this, errors could be chained this way using libraries like "verror", which is still very popular (21 million downloads/week) at the time of writing.
Handling errors in route handlers:
Inside a route handler, we will build a rich `Error` object in almost the same way as in library code, but we use the `restify_errors` library. This library is a wrapper for the verror library mentioned above, which is designed for servers and provides status codes.
The `creationError` in example 2a is an example of this. Let's look at it again:
`const creationError = new restify_errors.InternalServerError(err, "Failed to create subscription.")`
There are three pieces of information here:
First, we are creating an `InternalServerError`. This is meaningful to Restify, and means the HTTP status code will be 500.
Second, we pass in the original error object. This is similar to the wrapping/chaining we discussed in the library code.
Third, we add a new error message to describe what is happening in the current context.
Together, this creates a rich error object. Next we send it to the client:
return next(creationError)
Passing the error to next() means that the server will handle the error gracefully - it will send the status code and message to the client and continue along happily.
Logging:
Examples 1b and 2b are library code. We do not log in library code.
In example 2a, we log at the "warning" level. This log level is for things that are probably not right (e.g. the user made a request but sent us the wrong data) but which the server is comfortable with. In general, if we catch the error, it is at most a warning.
When logging errors, pass the entire `Error` object into the logger. This means that even though library code does not log, the information it passes back in its rich errors will appear in the log statements.
In example 1a, we decide not to log at all, because it is a very "routine" type of error. The client will receive a message about the error, and nothing else needs to happen.
Uncaught Errors
All of the above examples discuss errors that are caught, or an "operational error".
When something goes wrong that we do not expect, and the exception is uncaught, this a "programming error", and this is bad.
Programming errors will crash the process and cause it to exit. This is good! We do not want to try to recover from an uncaught exception, because an uncaught exception by nature means that the code is in an unsafe state, and we want these kinds of bugs to have high visibility. In production, the server runs in a clustered mode with multiple processes, and they automatically restart whenever they crash, so uptime is preserved.
In this case, we have limited control over the information we add to the Error, and we cannot send anything to the client. Because the server crashes, the client will have an ERR_NO_RESPONSE error (or the equivalent).
However, these errors by nature represent bugs in our code, and it is very important that we log these errors and the stack trace for them, so that we can address them.
It may be tempting to ask if the server has a listener for uncaught exceptions, but this is actually the wrong approach. When the server throws an uncaught exception, it is done. We can however listen to the process exiting:
// Nice exit handling (logs uncaught errors!)
// Taken from: https://blog.heroku.com/best-practices-nodejs-errors
const exitHandler = (code, reason) => (err, promise) => {
if (err && err instanceof Error) {
server.logger.error(reason, err.message, err.stack)
}
server.close(code => process.exit(code)) // attempt graceful shutdown
setTimeout(code => process.exit(code), 500).unref() // if not shutdown in 0.5 seconds, just kill it (unref detaches the reference to this timer, which would normally keep the process open, so that server.close() can kill the process itself)
}
process.on("uncaughtException", exitHandler(1, "Unexpected Error"))
process.on("unhandledRejection", exitHandler(1, "Unhandled Promise"))
process.on("SIGTERM", exitHandler(0, "SIGTERM"))
process.on("SIGINT", exitHandler(0, "SIGINT"))
This code will listen to the `uncaughtException` and `unhandledRejection` events of the node process itself, and log them for us before killing the process. It will also close the server process to further connections, and give it half a second to finish what it is doing and quit on its own. If it doesn't quit on its own, it is forcibly exited. This allows the server to log uncaught errors AND gracefully shutdown when they are caught. These errors are logged at the "error" level because they are truly errors and we want them to have high visibility.
More notes:
Restify Error listeners:
You could use this:
server.on("restifyError", function(req, res, err, callback) {
server.logger.error("Uncaught app server error: ", err)
return callback() // if you don't have this, the server gets stuck
})
It runs on any error passed to next(). This isn't really helpful for us though, because we just handle those errors when they happen.
Gotchas when calling next():
Calling `next()` with an error will cause Restify to send a response to the client. However, it will not stop execution of the current code, nor will it kill any async operations related to the handler code which are running in the background. There are two things to keep in mind:
1. Always do `return next()` to halt the operation right there.
2. If you have async operations running that you do not wait for, their callbacks/thens/resolution code needs to check if the response has already been sent, otherwise they may try to send a response to the same request a second time, which will almost certainly throw an exception that you won't catch. This is not specific to error handling, but it is more likely to be a surprise to you that the request was already responded to, if it was responded to by an error handling block. In general, it is also a good idea to `await` everything that you can. After all, you don't want to send a success message to the client if the operations have not succeeded yet.
back