Error Handling
We’ve already seen several examples of how Promise rejection — either intentional through calling reject(..)
or accidental through JS exceptions — allows saner error handling in asynchronous programming. Let’s circle back though and be explicit about some of the details that we glossed over.
The most natural form of error handling for most developers is the synchronous try..catch
construct. Unfortunately, it’s synchronous-only, so it fails to help in async code patterns:
function foo() {
setTimeout( function(){
baz.bar();
}, 100 );
}
try {
foo();
// later throws global error from `baz.bar()`
}
catch (err) {
// never gets here
}
try..catch
would certainly be nice to have, but it doesn’t work across async operations. That is, unless there’s some additional environmental support, which we’ll come back to with generators in Chapter 4.
In callbacks, some standards have emerged for patterned error handling, most notably the “error-first callback” style:
function foo(cb) {
setTimeout( function(){
try {
var x = baz.bar();
cb( null, x ); // success!
}
catch (err) {
cb( err );
}
}, 100 );
}
foo( function(err,val){
if (err) {
console.error( err ); // bummer :(
}
else {
console.log( val );
}
} );
Note: The try..catch
here works only from the perspective that the baz.bar()
call will either succeed or fail immediately, synchronously. If baz.bar()
was itself its own async completing function, any async errors inside it would not be catchable.
The callback we pass to foo(..)
expects to receive a signal of an error by the reserved first parameter err
. If present, error is assumed. If not, success is assumed.
This sort of error handling is technically async capable, but it doesn’t compose well at all. Multiple levels of error-first callbacks woven together with these ubiquitous if
statement checks inevitably will lead you to the perils of callback hell (see Chapter 2).
So we come back to error handling in Promises, with the rejection handler passed to then(..)
. Promises don’t use the popular “error-first callback” design style, but instead use “split callbacks” style; there’s one callback for fulfillment and one for rejection:
var p = Promise.reject( "Oops" );
p.then(
function fulfilled(){
// never gets here
},
function rejected(err){
console.log( err ); // "Oops"
}
);
While this pattern of error handling makes fine sense on the surface, the nuances of Promise error handling are often a fair bit more difficult to fully grasp.
Consider:
var p = Promise.resolve( 42 );
p.then(
function fulfilled(msg){
// numbers don't have string functions,
// so will throw an error
console.log( msg.toLowerCase() );
},
function rejected(err){
// never gets here
}
);
If the msg.toLowerCase()
legitimately throws an error (it does!), why doesn’t our error handler get notified? As we explained earlier, it’s because that error handler is for the p
promise, which has already been fulfilled with value 42
. The p
promise is immutable, so the only promise that can be notified of the error is the one returned from p.then(..)
, which in this case we don’t capture.
That should paint a clear picture of why error handling with Promises is error-prone (pun intended). It’s far too easy to have errors swallowed, as this is very rarely what you’d intend.
Warning: If you use the Promise API in an invalid way and an error occurs that prevents proper Promise construction, the result will be an immediately thrown exception, not a rejected Promise. Some examples of incorrect usage that fail Promise construction: new Promise(null)
, Promise.all()
, Promise.race(42)
, and so on. You can’t get a rejected Promise if you don’t use the Promise API validly enough to actually construct a Promise in the first place!
Pit of Despair
Jeff Atwood noted years ago: programming languages are often set up in such a way that by default, developers fall into the “pit of despair” (http://blog.codinghorror.com/falling-into-the-pit-of-success/) — where accidents are punished — and that you have to try harder to get it right. He implored us to instead create a “pit of success,” where by default you fall into expected (successful) action, and thus would have to try hard to fail.
Promise error handling is unquestionably “pit of despair” design. By default, it assumes that you want any error to be swallowed by the Promise state, and if you forget to observe that state, the error silently languishes/dies in obscurity — usually despair.
To avoid losing an error to the silence of a forgotten/discarded Promise, some developers have claimed that a “best practice” for Promise chains is to always end your chain with a final catch(..)
, like:
var p = Promise.resolve( 42 );
p.then(
function fulfilled(msg){
// numbers don't have string functions,
// so will throw an error
console.log( msg.toLowerCase() );
}
)
.catch( handleErrors );
Because we didn’t pass a rejection handler to the then(..)
, the default handler was substituted, which simply propagates the error to the next promise in the chain. As such, both errors that come into p
, and errors that come after p
in its resolution (like the msg.toLowerCase()
one) will filter down to the final handleErrors(..)
.
Problem solved, right? Not so fast!
What happens if handleErrors(..)
itself also has an error in it? Who catches that? There’s still yet another unattended promise: the one catch(..)
returns, which we don’t capture and don’t register a rejection handler for.
You can’t just stick another catch(..)
on the end of that chain, because it too could fail. The last step in any Promise chain, whatever it is, always has the possibility, even decreasingly so, of dangling with an uncaught error stuck inside an unobserved Promise.
Sound like an impossible conundrum yet?
Uncaught Handling
It’s not exactly an easy problem to solve completely. There are other ways to approach it which many would say are better.
Some Promise libraries have added methods for registering something like a “global unhandled rejection” handler, which would be called instead of a globally thrown error. But their solution for how to identify an error as “uncaught” is to have an arbitrary-length timer, say 3 seconds, running from time of rejection. If a Promise is rejected but no error handler is registered before the timer fires, then it’s assumed that you won’t ever be registering a handler, so it’s “uncaught.”
In practice, this has worked well for many libraries, as most usage patterns don’t typically call for significant delay between Promise rejection and observation of that rejection. But this pattern is troublesome because 3 seconds is so arbitrary (even if empirical), and also because there are indeed some cases where you want a Promise to hold on to its rejectedness for some indefinite period of time, and you don’t really want to have your “uncaught” handler called for all those false positives (not-yet-handled “uncaught errors”).
Another more common suggestion is that Promises should have a done(..)
added to them, which essentially marks the Promise chain as “done.” done(..)
doesn’t create and return a Promise, so the callbacks passed to done(..)
are obviously not wired up to report problems to a chained Promise that doesn’t exist.
So what happens instead? It’s treated as you might usually expect in uncaught error conditions: any exception inside a done(..)
rejection handler would be thrown as a global uncaught error (in the developer console, basically):
var p = Promise.resolve( 42 );
p.then(
function fulfilled(msg){
// numbers don't have string functions,
// so will throw an error
console.log( msg.toLowerCase() );
}
)
.done( null, handleErrors );
// if `handleErrors(..)` caused its own exception, it would
// be thrown globally here
This might sound more attractive than the never-ending chain or the arbitrary timeouts. But the biggest problem is that it’s not part of the ES6 standard, so no matter how good it sounds, at best it’s a lot longer way off from being a reliable and ubiquitous solution.
Are we just stuck, then? Not entirely.
Browsers have a unique capability that our code does not have: they can track and know for sure when any object gets thrown away and garbage collected. So, browsers can track Promise objects, and whenever they get garbage collected, if they have a rejection in them, the browser knows for sure this was a legitimate “uncaught error,” and can thus confidently know it should report it to the developer console.
Note: At the time of this writing, both Chrome and Firefox have early attempts at that sort of “uncaught rejection” capability, though support is incomplete at best.
However, if a Promise doesn’t get garbage collected — it’s exceedingly easy for that to accidentally happen through lots of different coding patterns — the browser’s garbage collection sniffing won’t help you know and diagnose that you have a silently rejected Promise laying around.
Is there any other alternative? Yes.
Pit of Success
The following is just theoretical, how Promises could be someday changed to behave. I believe it would be far superior to what we currently have. And I think this change would be possible even post-ES6 because I don’t think it would break web compatibility with ES6 Promises. Moreover, it can be polyfilled/prollyfilled in, if you’re careful. Let’s take a look:
- Promises could default to reporting (to the developer console) any rejection, on the next Job or event loop tick, if at that exact moment no error handler has been registered for the Promise.
- For the cases where you want a rejected Promise to hold onto its rejected state for an indefinite amount of time before observing, you could call
defer()
, which suppresses automatic error reporting on that Promise.
If a Promise is rejected, it defaults to noisily reporting that fact to the developer console (instead of defaulting to silence). You can opt out of that reporting either implicitly (by registering an error handler before rejection), or explicitly (with defer()
). In either case, you control the false positives.
Consider:
var p = Promise.reject( "Oops" ).defer();
// `foo(..)` is Promise-aware
foo( 42 )
.then(
function fulfilled(){
return p;
},
function rejected(err){
// handle `foo(..)` error
}
);
...
When we create p
, we know we’re going to wait a while to use/observe its rejection, so we call defer()
— thus no global reporting. defer()
simply returns the same promise, for chaining purposes.
The promise returned from foo(..)
gets an error handler attached right away, so it’s implicitly opted out and no global reporting for it occurs either.
But the promise returned from the then(..)
call has no defer()
or error handler attached, so if it rejects (from inside either resolution handler), then it will be reported to the developer console as an uncaught error.
This design is a pit of success. By default, all errors are either handled or reported — what almost all developers in almost all cases would expect. You either have to register a handler or you have to intentionally opt out, and indicate you intend to defer error handling until later; you’re opting for the extra responsibility in just that specific case.
The only real danger in this approach is if you defer()
a Promise but then fail to actually ever observe/handle its rejection.
But you had to intentionally call defer()
to opt into that pit of despair — the default was the pit of success — so there’s not much else we could do to save you from your own mistakes.
I think there’s still hope for Promise error handling (post-ES6). I hope the powers that be will rethink the situation and consider this alternative. In the meantime, you can implement this yourself (a challenging exercise for the reader!), or use a smarter Promise library that does so for you!
Note: This exact model for error handling/reporting is implemented in my asynquence Promise abstraction library, which will be discussed in Appendix A of this book.