Promises and Error Handling

We’ve standardized on using promises to manage most of the asynchronous interactions in our JavaScript codebase. In addition to really enjoying the extra expressiveness and aggregation possibilities offered by promises, we’re benefitting greatly from richer error handling. We’ve had some surprises though, and this post explains some things which caught us out and some guidelines we’re following to avoid similar situations in future.

We use the excellent when.js library, however this post should be relevant no matter what implementation you’re using.

image

1. Reject what you would normally throw

Rejecting a promise should be synonymous with throwing an exception in synchronous code, according to Domenic Denicola. We’ve previously built some promise interfaces which can reject with no value, or with a string error message. Rejecting with a string should be discouraged for the very same reasons as throwing strings. An empty rejection is the async equivalent of throw undefined, which (hopefully) nobody would consider doing.

These practices cause us additional problems with the likes of Express and Mocha since they expect their next and done callbacks to be invoked with an Error instance to trigger the failure flow, otherwise they consider it a successful operation. We, quite reasonably, are in the habit of chaining promise rejections straight onto these (e.g. .then(onResolve, next) or .catch(next)). If the promise rejects with no arguments then it won’t signal a failure when used like this! 

Guideline 1: Always reject promises with an Error instance. Do not reject with no arguments. Do not reject with non-Error objects, or primitive values.

It’s easy to feel like rejecting a promise is less ‘severe’ than throwing an exception, this impulse can lead to promises being rejected where normally you would not throw an exception (e.g. validation checks). If the above guideline is difficult to follow in a specific case because you can’t think of an appropriate Error type to reject with, then perhaps it shouldn’t be a rejection after all.

2. Understand how .catch works

We have run into trouble with catch (a.k.a. otherwise/fail for non-ES5 browsers). The documentation for it might lead you to think that somePromise.then(onResolve).catch(onReject) is equivalent to somePromise.then(onResolve, onReject). This appears true at first glance:

function onResolve(result) { console.log('OK', result) }
function onReject(err) { console.log('NOT OK', err.stack) }

// This:
somePromise.then(onResolve, onReject)

// Is equivalent to:
somePromise.then(onResolve).catch(onReject)

They differ, however, in how they respond to errors thrown in callbacks. If onResolve throws and we are using the .then(onResolve, onReject) form, onReject is not invoked, and the outer promise is rejected.

var outer = resolvedPromise.then(function onResolve(result) {
throw new Error('this is an error')
}, function onReject(err) {
// Never gets invoked
})

// outer is rejected with the 'this is an error' error

If onResolve throws and we are using the .then(onResolve).catch(onReject) form, onReject is invoked, and the outer promise is resolved.

var outer = resolvedPromise.then(function onResolve(result) {
throw new Error('this is an error')
}).catch(function onReject(err) {
console.log('FAILED', err)
// => FAILED [Error: this is an error]
})
// outer is resolved

So, throwing inside either handler will reject the outer promise with the thrown error.

Guideline 2: Anticipate failures in your handlers. Consider whether your rejection handler should be invoked by failures in the resolution handler, or if there should be different behavior.

3. Use .finally for cleanup

.finally (.ensure for non-ES5 environments) allows you to perform an action after a promise completes, without modifying the returned promise (unless the finally handler throws).

var outer = resolvedPromise.then(function onResolve(result) {
throw new Error('this is an error')
}).finally(function cleanup() {
actionButton.enabled = true
})

// outer is rejected with the first Error, as if the finally handler wasn't even there
var outer = resolvedPromise.then(function onResolve(result) {
console.log('resolved')
}).finally(function cleanup() {
throw new Error('failed during cleanup')
})

// outer is rejected with the 'failed during cleanup' Error

As hinted at in the example, this could be especially useful on the browser for re-enabling action buttons once an operation completes, regardless of the outcome.

Guideline 3: Use finally for cleanup

4. Terminating the promise chain

Guideline 4: Either return your promise to someone else, or if the chain ends with you, call done to terminate it. (from the Q docs)

This has bitten us many times when using promises in Express handlers, and resulted in hard-to-debug hung requests. This handler will never respond to the user:

function handler(req, res, next) {
service.doSomething().then(function onResolve(result) {
throw new Error('this is an error')
res.json({result: 'ok'})
}, function onReject(err) {
res.json({err: err.message}) // Never gets invoked
})
}

Changing the then in the above code to done means that there will be no outer promise returned from this, and the error will result in an asynchronous uncaught exception, which will bring down the Node process. In theory this makes it unlikely that any such problem would make it into production, given how loudly and clearly it would fail during development and testing.

An alternative is to add a final .catch(next) to the promise chain to ensure that any error thrown in either handler will invoke the Express error handler:

function handler(req, res, next) {
service.doSomething().then(function onResolve(result) {
throw new Error('this is an error')
res.json({result: 'ok'})
}, function onReject(err) {
res.json({err: err.message}) // Never gets invoked
})
.catch(next) // next is invoked with the 'this is an error' error
}

This goes against the above guideline, since we are creating a new promise rather than ending the promise chain. You could argue that we trust next not to throw and as such there is no chance for the outer promise to reject. In addition there is no possibility of a hanging request or an uncaught failure (unless you throw undefined in either handler!).

If the idea of done bringing down a Node process makes you uncomfortable enough to ignore this ‘golden rule’ of promise error-handling, then perhaps this is a good option. The important thing is that errors do not hang requests, or get quietly transformed into successes.

Summary

We’ve seen 4 simple guidelines which should feel familiar if you’ve dealt with synchronous exception handling best practices. In fact they could almost be generalized to apply to both sync and async exception handling:

  1. Throw meaningful errors (and a string is not an error)
  2. Be aware of downstream effects of errors on the flow of execution
  3. Cleanup as soon as the error occurs
  4. Bubble exceptions up to a top level handler

This is a powerful feature of promises - letting us deal with errors in a stye that is more natural to us, as long as we are actually mindful of this, and remember to follow similar rules.

Jon Merrifield
http://github.com/jmerrifield
Engineer


We are hiring! If you want to come work with us and help empower people to Change the world while working on amazing technology check out our jobs page or email us directly: jmerrifield at change dot org

  1. codreamers reblogged this from making-change-org
  2. pixel67 reblogged this from making-change-org
  3. making-change-org posted this