Error handling can be a drag, but it’s essential for the stability of your app. Naturally, I’m interested in ways to streamline the error handling process to make it as stable as it can be for the app whilst also being convenient for me to write.
Lately, I’ve been reading a lot about new features in ES6, such as generators, and that’s led me onto promises, an alternative method of asynchronous flow control to callbacks. I decided to look into the differences in how these different methods approach error handling, their strengths and weaknesses.
I/O errors: callbacks vs promises
The main kind of errors we’re looking at here are I/O errors in asynchronous operations. These occur when an I/O operation fails to yield the expected results, sometimes due to some external problem outside of your program’s control. For example, we might be fetching data from a MySQL database, but our query contains an error:
// We misspell 'SELECT' in this query so it fails
var query = 'SLECT 1 + 1';
con.query(query, function(err){
// query failed. what do we do now?
});
Callbacks
Notice that in this example we are using Node’s default style of using a callback to handle the result of the I/O. The first argument of the callback function is err
. This is the standard convention in Node, the one you should follow when writing your own async functions.
The first argument to callbacks should always be err
Developers new to Node sometimes forget to follow this convention which makes it very frustrating for other developers trying to work with their code, because they have no consistent point of reference to check whether the operation succeeded or not. But if the first parameter to our callback is reserved for errors then they can be checked before processing the results of each callback.
If err
is falsy (usually null), then the callback can carry on assuming the operation succeeded. Otherwise, it can deal with the error in an appropriate way, such as logging it along with any contextual information. It can then decide whether or not to carry on depending on the severity of the error or whether or not the resultant data is required to continue operation.
Let’s implement some error handling for our query error:
var log = console.log;
// We misspell 'SELECT' in this query so it fails
var query = 'SLECT 1 + 1';
con.query(query, function(err){
if (err) return log("Query failed. Error: %s. Query: %s", err, query);
});
Here, we check if err
is present. If it is, we log the error and the query that triggered it then return from the function, stopping it from running any further.
Parallelism
You might have a collection of multiple async operations executing in parallel. How do we handle errors in any of those?
Our favourite library for asynchronous flow control is async. Both async.parallel
and async.series
accept a collection of operations, and if any of them pass an error to its callback, async will immediately invoke your completion callback with the error:
var async = require('async');
var log = console.log;
var op1 = function(cb) {
// We misspell 'SELECT' in this query so it fails
var query = 'SLECT 1 + 1';
con.query(query, cb);
}
var op2 = function(cb) {
// This query is fine
con.query('SELECT 1 + 1', cb);
}
var ops = [op1, op2];
async.parallel(ops, function(err, results) {
if (err) return log("Something went wrong in one of our ops. Err: %s", err);
// Otherwise, process results
});
async.parallel
will execute both op1
and op2
in parallel but if either or both fail it will invoke our completion callback with the error that occurred first.
Standard callbacks are all well and good when we’re following Node’s convention, but it’s a little bit laborious to check the result of every operation, and this can quickly get messy when there are many nested callbacks each with their own error handling code.
Promises
Promises are an alternative to callbacks for asynchronous control flow. They are viewed as a solution to the “pyramid of doom” indentation caused by nested callbacks, but they also have some useful error handling features.
Q is a popular module to get you working with promises. In its README, Q describes the concept of promises:
If a function cannot return a value or throw an exception without blocking, it can return a promise instead. A promise is an object that represents the return value or the thrown exception that the function may eventually provide.
Promises allow us to chain operations together in a sequential manner, without the need for nesting. They also neatly encapsulate any results and errors raised within the chain. For example:
var Q = require('Q');
Q.fcall(op1)
.then(op2)
.catch(function(err) {
// An exception was thrown in any of the ops
log("Something went wrong");
})
.done();
Compare this to the callback-based equivalent:
op1(function(err) {
if (err) {
return log('Something went wrong');
}
op2(function(err) {
if (err) {
return log('Something else went wrong');
}
});
});
✔ Clear & compact
The promises method is much more compact, clearer and quicker to write. If an error or exception occurs within any of the ops it is handled by the single .catch()
handler. Having this single place to handle all errors means you don’t need to write error checking for each stage of the work.
✔ Exception handling
Additionally, the promise chain has more robust protection against exceptions and runtime errors that could be thrown within operations. If an exception is thrown, it will be caught by your .catch()
handler, or any intermediary error handlers passed to each .then
step. In contrast, the callback method would crash the node process because it doesn’t encapsulate exceptions thrown in I/O callbacks. Catching exceptions like this allows you to gracefully handle the error in an appropriate way instead of crashing the process straight away.
✔ Better stack traces
Furthermore, you can use Q’s long stack support to get more helpful stack traces that keep track of the call stack across asynchronous operations.
✘ Compatibility
One slight disadvantage of promises is that in order to use them, you need to make any normal node callback-style code compatible with promise flow control. This usually involves passing the functions through an adapter to make it compatible with promises, such as Q’s Q.denodify(fn)
.
Error events
We’ve spoken a lot about I/O errors during asynchronous flow control, but Node.js has another way of running handlers asynchronously: events.
In Node, an object can be made into an event emitter by inheriting the EventEmitter on its prototype. All core node modules that emit events such as net.Socket
or http.Server
inherit from EventEmitter.
The special ‘error’ event
When an event emitter encounters an error (e.g. a TCP socket abruptly disconnects), it will emit a special ‘error’ event. The ‘error’ event is special in Node because if there are no listeners on the event emitter for this event then the event emitter will throw an exception and this will crash the process.
Handling uncaught exceptions
You might be tempted to prevent exceptions being thrown by binding a listener to the ‘error’ event and logging it instead of crashing. This is potentially dangerous, because you usually can’t guarantee exactly where the error originated from and what all the consequences of it are. Usually the best thing to do is catch the error, log it, close any existing connections and gracefully restart your app.
Do not use process.on('uncaughtException')
process.on('uncaughtException')
was added to node for the purpose of catching errors and doing cleanup before the node process exits. Beware! This has quickly become an anti-pattern in Node. It loses the context of the exception, and is prone to hanging if your event handler doesn’t call process.exit()
.
Domains
Domains were created as a more official way of capturing uncaught exceptions or stray ‘error’ events before they get to crash the process.
While the domains API is still in unstable state and is subject to some criticism, it is still better than using process.on('uncaughtException')
. Read the docs for usage info.