Error Handling - JavaScript with Promises (2015)

JavaScript with Promises (2015)

Chapter 5. Error Handling

One of the biggest benefits of using promises is the way they allow you to handle errors. Async error handling with callbacks can quickly muddy a codebase with boilerplate checks in every function. Fortunately, promises allow you to replace those repetitive checks with one handler for a series of functions.

The error handling API for promises is essentially one function named catch. However, there are some extra things to know when using this function. For instance, it allows you to simulate an asynchronous try/catch/finally sequence. And it’s easy to unintentionally swallow errors by forgetting to rethrow them inside a catch callback.

This chapter guides you through error handling in practice so you can write robust code. It includes examples using the standard Promise API as well as options the Bluebird promise library offers.

Rejecting Promises

Basic error handling with promises was introduced in Chapter 2 using the catch method. You saw how a rejected promise invokes callbacks registered with catch (repeated in Example 5-1.)

Example 5-1. Explicitly rejecting a promise

var rejectedPromise = new Promise(function (resolve, reject) {

reject(new Error('Arghhhh!')); // Explicit rejection

});

rejectedPromise.catch(function (err) {

console.log('Rejected');

console.log(err);

});

// Console output:

// Rejected

// [Error object] { message: 'Arghhhh!' ... }

The rejectedPromise was explicitly rejected inside the callback given to the Promise constructor. As shown in Example 5-2, a promise is also rejected when an error is thrown inside any of the callbacks the promise invokes (i.e., any callback passed to the Promise constructor, then, or catch.)

Example 5-2. Unhandled error rejects a promise

var rejectedPromise = new Promise(function (resolve, reject) {

throw new Error('Arghhhh!'); // Implicit rejection

});

Any error that occurs in a function that returns a promise should be used to reject the promise instead of being thrown back to the caller. This approach allows the caller to deal with any problems that arise by attaching a catch handler to the returned promise instead of surrounding the call in a try/catch block. This can be done by wrapping code with a Promise constructor. Example 5-3 shows two functions to illustrate the difference between throwing a synchronous error and implicitly rejecting a promise.

Example 5-3. Functions that return promises should not throw errors

function badfunc(url) {

var image;

image.src = url; // Error: image is undefined

return new Promise(function (resolve, reject) {

image.onload = resolve;

image.onerror = reject;

});

}

function goodfunc(url) {

return new Promise(function (resolve, reject) {

var image;

image.src = url; // Error: image is undefined

image.onload = resolve;

image.onload = reject;

});

}

Runtime errors occur in both functions because the image object is never instantiated. In badfunc only a try/catch block somewhere up the stack or a global exception handler will catch the runtime error. In goodfunc the runtime error rejects the returned promise so the calling code can deal with it in the same way as any other problems that may arise from the operation the promise represents.

TIP

Any error that occurs in a function that returns a promise should be used to reject the promise instead of being thrown back to the caller.

Passing Errors

Using uncaught errors to reject promises provides an easy way to pass errors across different parts of an application and to handle them at the place of your choosing. As you saw in the last section, all the code you write inside promise callbacks is wrapped in an implicit try block; you just need to provide the catch. When presented with a chain of promises, add a catch wherever it is helpful to deal with a rejection. Although you could add a catch handler to every promise in a chain, it is generally practical to use a single handler at the end of the chain.

Where does a promise chain end? It’s common for a single function to contain a chain of several promises that a series of calls to then defines. However, the chain may not end there. Promise chains are frequently extended across functions as each caller appends a promise to the tail.

Consider an async function that opens a database connection, runs a query, and returns a promise that is fulfilled with the resulting data. The function returns the last promise in the chain but the calling function will add to that chain so it can do something with the results, as shown inExample 5-4. The pattern continues as the new tail is returned to the next calling function, as described in “The Async Ripple Effect”.

Example 5-4. Promise chains built across functions

var db = {

connect: function () {/*...*/},

query: function () {/*...*/}

};

function getReportData() {

return db.connect().then(function (connection) {

return db.query(connection, 'select report data');

});

}

getReportData().then(function (data) {

data.sort();

console.log(data);

}).catch(function (err) {

console.log('Unable to show data');

});

In this code the promise chain ends at the bottom of the script after the data is sorted and written to the console. The end of the chain has a catch function to handle any problems that may occur along the way. Since the chain does not terminate in getReportData, it is not necessary to include a catch function there. However, you may wish to include one to put some logging statements close to the source of a potential error.

The catch function returns a new promise similar to then, but the promise that catch returns is only rejected if the callback throws an error. In other words, you must explicitly rethrow an error inside a catch callback if you want the rejection to continue propagating through the promise chain. Example 5-5 shows an updated version of getReportData that includes a handler to log errors.

Example 5-5. Logging and rethrowing an error

function getReportData() {

return db.connect().then(function (connection) {

return db.query(connection, 'select something');

}).catch(function (err) {

console.log('An error occurred while getting the data');

if (err && err.message) console.log(err.message);

throw err; // Must re-throw if you want the rejection to propagate further

});

}

If db.connect() or db.query() return promises that are rejected and the catch callback in getData does not include the throw statement, then getData will always return a resolved promise. In this case a runtime error would occur when data.sort() is called because the value ofdata would be undefined.

Unhandled Rejections

It’s easy to forget to add a catch handler to your promise chain. You may start by writing code for the happy path and consider your work done once things behave as expected. Missing a catch handler can be difficult to troubleshoot because the rejected promise sits silently somewhere in your codebase, as opposed to traditional runtime errors that are immediately written to the console and may bring your application to a halt.

Bluebird implements one solution to this problem. After the rejection of a bluebird promise, the console displays the reason if no catch handlers are registered for the rejection by the time the event loop turns twice, as shown in Example 5-6. Waiting for two turns of the loop gives your code time to deal with a rejected promise, which reduces the chance of a handled rejection showing up in the console.

Example 5-6. Bluebird reporting an unhandled rejection

Bluebird.reject('No one listens to turtle');

// Console output:

// Possibly unhandled Error: No one listens to turtle

// at Function.Promise$Reject ...

The developer tools in your web browser may also report unhandled rejections. At the time this book was written, Chrome and Mozilla Firefox both did this but in slightly different ways. Chrome logged unhandled rejections immediately whereas Firefox waited until garbage collection occurred. The Firefox approach introduces a delay but eliminates false positives (i.e., showing a rejection that is eventually handled.)

Implementing try/catch/finally

A try/catch/finally flow allows you to run some code, handle any exceptions that the code throws, and then run some final code regardless of whether an exception occurred. To see why this is useful, first consider the following function in Example 5-7, which fetches some data and uses the performance.now() Web API to log the amount of time taken.

Example 5-7. A try/catch block

function getData() {

var timestamp = performance.now();

try {

// Fetch data

// ...

} catch (err) {

// Deal with any errors that arise

// ...

}

console.log('getData() took ' + (performance.now() - timestamp));

}

The log statement always runs regardless of whether an error occurs inside the try block because catch handles any error. Unfortunately this approach swallows the errors, so the code that calls getData never knows when an error occurs. In order to inform the calling code, the catchblock needs to rethrow the error, but that will bypass the log statement. That’s where the finally block comes in.

Example 5-8 is an example of a traditional try/catch/finally block.

Example 5-8. A traditional try/catch/finally block

function getData() {

var timestamp = performance.now();

try {

// Fetch data

// ...

} catch (err) {

// Bubble error up to code that called this function

throw err;

} finally {

// Log time taken regardless of whether the preceding code throws an error

console.log('getData() took ' + (performance.now() - timestamp));

}

}

// Console output:

// getData() took 0.030000000158906914

You can create an asynchronous try/catch/finally block using promises. We’ve already seen how any errors thrown within a promise chain are sent to the next catch callback in the chain, similar to using traditional try/catch blocks. To implement the finally portion, follow the call to catch with then and do not rethrow the error provided to catch, as shown in Example 5-9.

Example 5-9. Use catch/then to mimic catch/finally

function getData() {

var dataPromise;

var timestamp = performance.now();

dataPromise = new Promise(function (resolve, reject) {

// ...

throw new Error('Unexpected problem');

});

dataPromise.catch(function (err) {

// Do not rethrow error

}).then(function () {

// Simulates finally block

console.log('Data fetch took ' + (performance.now() - timestamp));

});

// Return data promise instead of catch/then tail to propagate rejection

return dataPromise;

}

The code in Example 5-9 creates a chain of three promises: the dataPromise, the promise returned by catch, and the promise returned by then. The promise returned by catch is always fulfilled because no error is thrown inside the callback given to catch. That promise executes the callback passed to then, which contains the same code that would have been placed in a finally block.

Some promise libraries, including Bluebird, implement a promise.finally() method for convenience. This method runs regardless of whether the promise is fulfilled or rejected and returns a promise that is settled in the same way. Example 5-10 shows a revised version of getDatausing bluebirdPromise.finally().

Example 5-10. Bluebird’s promise.finally()

function getData() {

var timestamp = performance.now();

return new Bluebird(function (resolve, reject) {

// ...

throw new Error('Unexpected problem');

}).finally(function () {

console.log('Data fetch took ' + (performance.now() - timestamp));

});

}

The revised code is simpler because bluebirdPromise.finally() can remove the explicit promise variable and catch function needed to mimic a finally block using the standard Promise API.

Using the Call Stack

It is often helpful to examine the call stack when troubleshooting code because it answers the question How did I get here? Whenever a function is invoked, the line that called the function is added to the stack. When an error occurs, the stack contains the trail of calls that shows how the machine arrived at that point. A typical view of the stack lists the name of each function in the trail and the line number of the code that called the next function.

The JavaScript call stack starts with whatever code the runtime inside the current turn of the event loop invoked. The stack continues to grow as that code calls another function, which in turn calls another function, etc. As each function returns, it is removed from the stack until the stack is empty, at which point the event loop turns again.

Example 5-11 shows a function that is called whenever clicking the mouse or pressing a key along with the associated call stack.

Example 5-11. Sample call stack

function echo(text) {

console.log(text);

throw Error('oops');

// Example of call stack for error when triggered by a mouse click:

// echo (line:3)

// showRandomNumber (line:12)

// handleClick (line:16)

}

function showRandomNumber() {

echo(Math.random());

}

document.addEventListener('click', function handleClick() {

showRandomNumber();

});

document.addEventListener('keypress', function handleKeypress() {

showRandomNumber();

});

The call stack shows you whether handleClick or handleKeypress triggered the echo function. In a larger program, knowing the execution path can go a long way toward finding the cause of a problem.

Unfortunately, the current call stack is generally not as helpful when promises are involved. In Example 5-12, we have revised Example 5-11 to call the echo function using promise.then(). As a result, the call stack inside echo no longer includes handleClick orshowRandomNumber.

Example 5-12. Promise callback breaks up the call stack

function echo(text) {

console.log(text);

throw new Error('oops');

// Example of call stack for error when invoked as a callback for a promise

// echo (line:3)

}

function showRandomNumber() {

// Invoking echo as a promise callback

var p = Promise.resolve(Math.random());

p.then(echo).catch(function (error) {

console.log(error.stack)

});

}

document.addEventListener('click', function handleClick() {

showRandomNumber();

});

document.addEventListener('keypress', function handleKeypress() {

showRandomNumber();

});

Why does using a promise callback appear to truncate the stack when compared to the earlier example? Remember that a promise invokes each callback in a separate turn of the event loop. At the beginning of each turn the stack is empty, so none of the functions called in previous turns appear in the stack when the error occurs.

Losing the stack between each callback makes troubleshooting harder. The problem is not unique to promises; it exists for any asynchronous callbacks. However, it can be a frequent source of frustration when using promises. To address this problem in the debugger, the Chrome team added an option to show the stack across turns of the event loop. Now you can see a stack that is stitched together at the points where asynchronous calls are made. A dedicated panel for debugging promises in the Chrome developer tools is also in the works. This is a huge help and other browsers may offer a similar feature by the time you read this.

You may also record errors that occur while people are using your software in the wild. When that happens, you don’t have the luxury of opening the debugger and looking at the stack. Developers have found clever ways to capture the async call stack using multiple Error objects. This is problematic because browsers expose the call stack for errors in different ways and it can degrade application performance. You can configure Bluebird to capture and report the stack trace across turns of the event loop by calling Bluebird.longStackTraces(). Keep in mind the impact on performance before enabling this option in the production version of your application.

Summary

Handling errors in asynchronous code cannot be done with traditional try/catch blocks. Fortunately, promises have a catch method for handling asynchronous errors. Although the method is a powerful tool for handling problems that occur deep within your code, you must use it properly to avoid silently swallowing errors. In addition to the functionality that the standard Promise API provides, libraries such as Bluebird offer extra error handling features. This includes the ability to report unhandled rejections and to capture the call stack across multiple turns of the event loop.