I am implementing a function called timeout
to convert a promise into a promise that will reject if not settled within a minimum amount of time.
With the following implementation, I get an uncaught (in promise)
message in Chrome devtools when the promise passed to timeout
rejects before the allotted time.
function timeout(promise, duration, message) {
const timeoutPromise = new Promise((_, reject) => {
const handle = setTimeout(() => {
reject(new TimeoutError(message ?? "Operation timed out."))
}, duration)
promise.finally(() => clearTimeout(handle)) //problem line
})
return Promise.race([promise, timeoutPromise])
}
If I change it to the following however, I seem to have no issues.
function timeout(promise, duration, message) {
const timeoutPromise = new Promise((_, reject) => {
const handle = setTimeout(() => {
reject(new TimeoutError(message ?? "Operation timed out."))
}, duration)
const clear = () => clearTimeout(handle)
promise.then(clear).catch(clear)
})
return Promise.race([promise, timeoutPromise])
}
I'm trying to understand how calling then
& catch
in the second scenario is different to calling finally
in the first.
This is because finally
handlers are different from then
and catch
handlers. If the promise is rejected, a finally
handler can't change rejection to resolution. Like the finally
block of a try
/catch
/finally
structure, it's meant to be largely transparent.
Here's how I describe it in my new book (Chapter 8):
In the normal case, a
finally
handler has no effect on the fulfillment or rejection passing through it (like afinally
block): any value it returns other than a thenable that is/gets rejected is ignored. But if it throws an error or returns a thenable that rejects, that error/rejection supersedes any fulfillment or rejection passing through it (just like throw in afinally
block). So it can't change a fulfillment value — not even if it returns a different value — but it can change a rejection reason into a different rejection reason, it can change a fulfillment into a rejection, and it can delay a fulfillment (by returning a thenable that is ultimately fulfilled).Or to put it another way: it's like a
finally
block that can't contain areturn
statement. (It might have athrow
or might call a function or do some operation that throws, but it can't return a new value.
Here's an example of not being able to convert rejection to fulfillment:
Promise.reject(new Error("Failed"))
.finally(() => {
console.log("finally handler returns null");
return null;
});
Look in the browser's real console to see the unhandled rejection error.
Another way to deal with it in your code is to do this in place of the problem line:
promise.catch(() => {}).finally(() => clearTimeout(handle))
or
promise.catch(() => {}).then(() => clearTimeout(handle))
That converts rejection to fulfillment (only on this branch) prior to the finally
(or then
) handler.