April 8, 2018
Catch This Javascript Error if You Can
A human I know came across code equivalent to this:
async function doSomething() {
console.log("doing stuff before an async task...");
await Promise.reject(new Error("I am the error"));
console.log("doing stuff after an async task...");
}
and asked
Can we catch this rejected promise without changing
doSomething()
?
My first thought was NodeJS can help (sort of)
-
v1.4.1 gained a global event for unhandled promise exceptions that you can listen to with
process.on('unhandledRejection', callbackFunction)
and provide your own callback to handle the rejection. -
v6.6.0 gained a feature where it emits a warning if a rejected promise isn’t handled
-
v7.0.0 made not handling promise rejections a deprecated feature. I found NodeJS v8.4.0 warns you with
(node:4828) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
What about browsers? Chrome and Safari support unhandledrejection
events a the moment, but nothing else does. If you don’t set up that handler and run doSomething()
in a browser, you will find:
-
Chrome 65 emits an error to the console:
Uncaught (in promise) Error: I am the error at doSomething (<anonymous>:2:26) at <anonymous>:1:1 doSomething @ VM603:2 (anonymous) @ VM611:1 async function (async) doSomething @ VM603:1 (anonymous) @ VM611:1
-
Firefox 59 emits the error, but doesn’t care that it is unhandled
Error: I am the error debugger eval code:2:26
-
Safari 10.1.2 swallows the error silently.
I haven’t tried IE 11 and Edge yet. But global unhandled rejected promises listeners aren’t what we were looking for.
Let’s be specific
What happens if you try this?
try {
doSomething();
console.log("everything is ok, no errors at all");
} catch (error) {
console.error("will it catch?", error);
}
The JS engine will log everything is ok, no errors at all
. Why? I thought it was because the error was never thrown with the keyword throw
, so there is nothing to catch with catch
. There’s just a rejected promise hanging out in memory somewhere.
What about:
Promise.resolve(doSomething()).then(
(result) => console.log("please do not run this callback"),
(error) => console.warn("will it catch?", error)
);
will it catch? Error: I am the error
at doSomething (<anonymous>:3:26)
at <anonymous>:1:17
That looks promising, but why does it work? doSomething()
does not look like it is going to throw an error or reject a promise. Let’s try it with a failed fetch
to see if i’m going crazy:
async function asyncRequest() {
console.log("doing stuff before fetching...");
await Promise.all([
// this fetch will work
fetch("https://jsonplaceholder.typicode.com/posts/1"),
// intentional typo in URL so this fetch fails
fetch("https://sonplaceholder.typicode.com/posts/2"),
]);
console.log("doing stuff after fetching...");
}
Promise.resolve(asyncRequest()).then(
(result) => console.log("ok js, please do not run this callback", result),
(error) => console.warn("will it catch?", error)
);
will it catch? TypeError: Failed to fetch
Promise.resolve.then.error @ VM534:14
Promise.then (async)
(anonymous) @ VM534:12
Why does this work? MDN says:
If the Promise is rejected, the await expression throws the rejected value.
But then why doesn’t this catch the error?
try {
asyncRequest();
} catch (error) {
console.warn(error);
}
Like we saw above, the global promise handler is invoked instead. What if:
asyncRequest().then(
(result) => console.log("ok js, please do not run this callback", result),
(error) => console.warn("will it catch?", error)
);
will it catch? TypeError: Failed to fetch
That’s a surprise! I’d never think of writing this. My asyncRequest
function does not explicity return a promise…
async
functions always return promises
It turns out async
functions always return promises. You can .then
an empty async function!
async function noop() {}
noop().then((result) => console.log("Implicit promise!", result));
What’s it going to say?
Implicit promise! undefined
You can .then
an async function that returns a primitive!
async function math() {
return 1 + 1;
}
math().then((result) => console.log("Implicit promise!", result));
Implicit promise! 2
If it throws an error, the error is wrapped in a promise and marked as rejected.
async function throwAnErrorAboutBananas() {
throw new Error("need more bananas");
}
throwAnErrorAboutBananas().then(
(result) => console.log("Implicit promise!", result),
(error) => console.error("catch this error!", error)
);
the error callback will be used:
catch this error! Error: need more bananas
at throwAnErrorAboutBananas (<anonymous>:2:10)
at <anonymous>:1:1
But the last 3 examples don’t use the await
keyword. Soooo back to this example that surprised me at first:
async function asyncRequest() {
console.log("doing stuff before fetching...");
await Promise.all([
// this fetch will work
fetch("https://jsonplaceholder.typicode.com/posts/1"),
// intentional typo so this fetch fails
fetch("https://sonplaceholder.typicode.com/posts/2"),
]);
console.log("doing stuff after fetching...");
}
asyncRequest().then(
(result) => console.log("please do not run this callback", result),
(error) => console.warn("will it catch?", error)
);
Here’s what happens, working outward:
fetch('https://sonplaceholder.typicode.com/posts/2')
fails because the URL does not exist, and returns a rejected promisePromise.all
sees one of the promises in the array is rejected, and returns a rejected promiseawait
sees the reject promise and throws an error, aborting execution earlyasyncRequest
is an async function, so it wraps the error in a promise - a rejected promise - and returns itasyncRequest
returned a promise (implicitly, just like the spec says), and the.then
call uses the error callback.
And why does this not catch the error?
async function asyncRequest() {
console.log("doing stuff before fetching...");
await Promise.all([
// this fetch will work
fetch("https://jsonplaceholder.typicode.com/posts/1"),
// intentional typo so this fetch fails
fetch("https://sonplaceholder.typicode.com/posts/2"),
]);
console.log("doing stuff after fetching...");
}
try {
asyncRequest();
} catch (error) {
console.warn(error);
}
fetch('https://sonplaceholder.typicode.com/posts/2')
fails because the URL does not exist, and returns a rejected promisePromise.all
sees one of the promises in the array is rejected, and returns a rejected promiseawait
sees the rejected promise and throws an error, aborting execution earlyasyncRequest
is an async function, so it wraps the error in a promise - a rejected promise - and returns ittry
doesn’t see the error get thrown - it just sees a rejected promise get returned, so it never triggers thecatch
- If the js runtime environment notices unhandled rejected promises, it warns us or exits
And now I feel slightly better educated.