The gotcha of unhandled promise rejections
jakearchibald.com
jakearchibald.com
In this case I'd want to log the error, or display a notice that the chapter couldn't be loaded. For this we need to catch the rejections and turn them into values we can iterate over with for await/of.
Promise.allSettled() does this for us, but forces us to wait until all promises are... settled. A streaming version would give back an async iterable to we could handle promises in order as soon as they're ready. Or we can just convert into the same return type as allSettled() ourselves:
async function showChapters(chapterURLs) {
const chapterPromises = chapterURLs.map(async (url) => {
try {
const response = await fetch(url);
return {status: "fulfilled", value: await response.json()};
} catch (e) {
return {status: "rejected", reason: e};
}
});
for await (const chapterData of chapterPromises) {
// Make sure this function renders or logs something for rejections
appendChapter(chapterData);
}
}He's trying to shove error handling into the wrong place (The loop should not be responsible for handling failed requests it knows nothing about).
But in general, if you're making a contract where a promise might reject and using await, you need to be wrapping that code in a try/catch somewhere.
That's the dealio with async/await and promises: you're trading the callback .then().catch() format for try{ await ... }catch(){}.
If you don't want to use try/catch - you need to be making a contract where the failure is not a rejection but an error object. If you want to use rejections, you need to be catching them (in one form or another).
Desired: when the load from chapter 10 fails, even if chapter 2 is taking 30s to load, I want to immediately transition the UI into the failure state.
Your code: waits until chapters 1-9 have loaded to report the failure state on chapter 10.
(I posted I believe a correct answer "without the hack" to the OP.)
My point is just that trying to do something with the failures often removes the warning and leads to better UX, or at least conscious decisions about it, and usually some clue in the code about what you intend to happen.
return Promise.race(Promise.all(promises), loop());
edit: I guess they are the same in this specific case, but probably wouldn't be in real code, making this function very clever but maybe too rigid.Say for example the loop was doing `result.push({success: true, value: item})` instead of just `result.push(item)`. Then the value returned from loop() is different from the one returned by Promise.all(promises), and your overall monitor() function might return either.
To make this loop early-cancellable on any out-of-order rejection, I’d create a helper generator and a “semaphore” promise, which would block the yield until there’s a next resolved value or any rejection.
const arr = []
arr.length = promises.length
let step, sem
function rearm() {
sem = new Promise(res => {step = res})
}
rearm()
promises.forEach((p, i) => {
p.then(v => step({i, v}))
.catch(e => step({i, e}))
})
async function* values() {
let next = 0
while (next < arr.length) {
if (arr[next]) {
yield arr[next++].v
continue
}
const {i, v, e} = await sem
rearm()
if (e) {
yield Promise.reject(e)
return
}
arr[i] = {v}
}
}
try {
for await (… of values()) {
…
I barely ever used js generators specifically, so this code is more like a concept (e.g. I suspect a race around rearm()), but you get the idea.Personally, I'd go with something like this:
async function showChapters(chapterURLs) {
return Promise.all(
chapterURLs
.map((url) => fetch(url).then(r => r.json()))
.map((p, i) => p.then(data => appendChapter(data, i), e => displayError(e, i)))
);
}
With this, you can control exactly where and how list items are rendered, regardless of order of promise resolution, and things are rendered as soon as they are available, instead of getting stuck behind slow requests.The requirements here seem a bit rare (ignore failures individually, machine gun requests, render incrementally), but the ideal solution is actually glossed over in the post, if you consider that you'll probably want to log those request errors in production:
async function showChapters(chapterURLs) {
const requests = chapterURLs.map(url => {
return fetch(url).then(r => r.json()).catch((err) => {
MyMonitoring.report(err)
})
})
for await (const data of requests) {
appendChapter(data);
}
}
In this scenario, and the post also fails to mention this, appendChapter() needs to deal with empty data as the .catch() handler is resolving to undefined.This could be easily abstracted into a 'fetchAll' function.
There is absolutely nothing "blunt" or wrong about it. The engine warned us about unhandled promises, we handle them, everybody is happy!
appendChapter(undefined) doesn't seem right to me. Feels like you'll have to do something like if (data === undefined) throw… which kinda shows how hacky this is.
promise.catch(() => {})
It does follow up with "this doesn't change the promises other than marking them as 'handled'. They're still rejected promises" but that doesn't seem right - they become fulfilled promises with a value of undefined, and those values will definitely show up in the for loop later. Is there something I'm missing with the nested promises?My suggestion was in the spirit of keeping this behaviour the same, except I think explicitly adding the catch handler to fetch() is the right way to do it vs an auxiliary function to 'silence' them.
No they don't, which is why I made that clarification in the post :D
const promise1 = Promise.reject(Error("wow"));
// promise1 is rejected, and unhandled
const promise2 = promise1.catch(() => {});
// promise2 will be fulfilled, and is unhandled
// promise1 is still rejected, but now handled
You might be okay in normal usage, but if a backlog accumulates, you could find yourself consuming all available resources and self destructing over and over.
I've tried it and do not recommend it.
It also looks like it'll display chapter 3 if chapter 2 fails to load, which seems wrong. At that point you've failed to display the sequence of chapters.
I mean, or you do any sort of sensible thing, like automatically retry the failed request (with backoff and a cap) directly in the catch, where the error is local, understood, and you have the information to handle it.
But in either case - you're right that it's no longer `Promise<chapter>`, it's now `Promise<Option<chapter, err>>` But... that's not a problem. And it's a much better way to indicate to other sections of the code what has happened than changing code paths completely with an exception or unhandled rejection.
Using something like an `Option` (or `Either`, or `Result` - depending on what library/languages you're familiar with) is a pretty sound way to handle errors in a pipeline, and is routinely found across basically every functional language out there.
Yeah, in this case I thought it might be nice to show a "failed to load" message for each failure. Maybe this is a chronological Mastodon timeline, or a photo gallery. Do you want to error the whole UI for one failed object? Maybe
But trying to do something useful with all failures it's a general rule that I think guides towards good thinking about UX and fewer unhandled rejection warnings.
The loop isn't the right spot to be catching an error that resulted from failing to fetch the data, or to parse the json data that was returned. The right spot was right there at the top of the file...
const chapterPromises = chapterURLs.map(async (url) => {
const response = await fetch(url);
return response.json();
});
This is where the error is occuring, and this is also the only reasonable spot to handle it. A very simple change to const chapterPromises = chapterURLs.map(async (url) => {
try {
const response = await fetch(url);
return response.json();
} catch(err) {
// Optionally - Log this somewhere...
return `Failed to fetch chapter data from ${url}` // Or whatever object appendChapter() expects with this as the content.
}
});
Solves the entire issue."There's no point showing additional chapters if an earlier one fails." <- This is not always true, and heavily dependent on what the use case is. For a book? sure. for a manual or textbook? Much less sure.
The approach works just fine either way, though - if you don't want to display additional chapters if one fails, you can do so easily by just including a "success" field (or anything similar) in the object that appendChapters expects, and break if needed when you hit that. (although again - you'd probably be better served by displaying a user readable error and offering a button to retry, and then displaying the rest of the content so you don't have to fetch it all a second time).
Alternatively, you can also have appendChapters throw, or if you really want, you can still leave the promise rejection - but leaving the rejection or throwing at this spot means you're making a contract that calls for `try/catch` to use without unhandled rejections.
That's the whole deal with async await. You're trading the formatting of .then().catch() for try{}catch(){}.
if you don't like writing try/catch blocks, either structure the contract so that the promise doesn't reject (at least for expected error cases), or use the traditional .then().catch().
In Typescript dealing with rejection is also painful since rejection reasons can't be guaranteed to be Error even when you always take care of that. And it can't help you guarantee that you're handling all types of errors thrown. For that purpose I'm thinking of using https://github.com/supermacro/neverthrow#readme or https://swan-io.github.io/boxed.
Speaking in the context of apps and webapps, so many bad UX moments are caused by async optimizations that are entirely pointless. Often I can't action the page until all the calls are done anyway, I would much rather see a finished page than watch the jittery pained birth of a dozen async calls coming to fruition and not knowing when it's safe to click something.
Personally - I don't want my entire UI to lock up on slow connections because the entire js context is paused waiting for a response that was fast for the dev on his dev machine sitting right next to the server, but is slow as fuck on my 3g connection pulling data at 1kb a second with fairly frequent packet loss.
I'd like to be able to click other links, navigate through menus, and interact with the site if I'm exploring and just happened to click on this page that's now loading a whole book.
Note that a real world implementation of this would also limit the number of concurrent requests - another reason to have streams with rich concurrency options.
Dart generally has fewer, but nicer, libraries for dealing with collections of Futures, but JS has a lot of npm libraries and is somewhat catching up with additions like Promise.{any,allSettled,race} and Array.fromAsync().
I removed Dart from the comparison.
Not to mention there are many great upsides to using observables as well!
I remember at the time Promise was gaining acceptance toward a standard there was a rich debate about adding observables and leveraging this as the async primitive but developers complained loudly that they wanted something more primitive.
Then everyone complained about Promise being too verbose, so we got async await and async iterables.
And now that we have said primitive, people say well where are the promise helpers, promises alone aren't enough!
All of this could have been avoided with observables
I gave the Dart API originally as an example, as it has careful considerations for when to return Future<T> instead of another Stream<T> (https://api.dart.dev/stable/2.18.7/dart-async/Stream-class.h...) - examples include first(), last(), asyncMap etc.
type Result<K = any, T extends Error = Error> = [K, null] | [null, T];
And use it: const [response, error] = await tryFetch<Type>("");
if (error) { handle(); } res = something(): Thing | Error
is I have to do if (res instanceof Error) {
handle()
return res
}
doSomethingWith(res)
or else the compiler will yell at me for trying to doSomethingWith(Error)Example: https://tsplay.dev/w2p0Vm
Idk though, I just use vanilla JS and don't find these safeguards necessary. Worst case, if I fail to handle something, my NodeJS express server will handle it by sending back a 500.
I feel like that’s the entire point of errors and try/catch?
As in most things coding, you can accomplish the same thing with either approach, but I believe by returning an `| Error` there's a better chance for the compiler to catch a bug before I run the code.
But I can imagine it might be a bit hard in Typescript due to the async nature.
Structured concurrency is one approach to solving this problem. In a structured concurrency a promise would not go out of scope unhandled. Not sure how you would add APIs for it though in Javascript.
See Python's trio nurseries idea which uses a python context manager.
https://github.com/python-trio/trio
I'm working on a syntax for state machines and it could be used as a DSL for promises. It looks similar to a bash pipeline but it matches predicates similar to prolog.
In theory you could wire up a tree (graph if you have joins) of structured concurrency with this DSL.
https://github.com/samsquire/ideas4#558-assign-location-mult...
[1] https://docs.python.org/3/library/asyncio-task.html#asyncio....
Funny, I used this very analogy about concurrency the other day. I think we live in a manually managed concurrency era, because we haven’t entirely figured out the right mix of patterns to get to the point when it makes sense. In the future, I expect all non-low-level languages simply agrees on one or two models, just like RAII and GC solved the manual memory management problem. That, and we still have a lot of bugs from concurrency related matters which we basically have no way to test.
Structured concurrency is most certainly part of the solution, but I there are a few devils in the details that are a lot of work (or careful thought) to get right.
I would really enjoy talking to you more about this.
From what you wrote, I agree, we are indeed in an era where concurrency and its notation has yet to settle on an elegant approach.
With microservices and distributed systems simultaneous independent execution and ordering and consistency then the problems of concurrency reveal themselves.
Do you have any ideas how you would want to define concurrency? What notation would you like to be capable of writing? What is your favourite representation of concurrency? (Such as bash pipelines)
Seems that could hide more bugs when someone keeps a reference to a big set of promises for some other reason - keeping a reference to something shouldn't change that things behaviour.
I can see the appeal, but I can also see why they didn’t go that way.
const thing = await fn();
if (thing?.errno) {
//handle error
return;
}
// handle result
Maybe I spent too much time writing C code. There are very few times when I actually want a rejection to take an entirely different path through my code, and this feels like the correct way to write it. async function showChapters(chapterURLs) {
const chapterPromises = chapterURLs.map(async (url) => {
const response = await fetch(url);
return response.json();
});
for (const i in chapterPromises) {
try {
appendChapter(await chapterPromises[i]));
} catch (error) {
handleChapterErrorGracefully(error, i);
}
}
}
I used for..in so that the index can be passed to handleChapterErrorGracefully async function showChapters(chapterURLs) {
const chapterGenerator = function*() {
for(const url of chapterURLs) {
yield fetch(url).then(response => response.json());
}
};
for (const chapter of chapterGenerator()) {
try {
appendChapter(await chapter));
} catch (error) {
handleChapterErrorGracefully(error);
}
}
} - Promises are created: 1,2,3,4
- The for loop awaits promise 1
- Promise 3 is rejected
- Promise 1 is resolved
- The for loop awaits promise 2
- …
Somewhere in this, promise 3’s rejection would be considered unhandled (I’m not sure exactly when).Yes, it might finish over the network before promise 1, but it won't be realized by the program until its await occurs in the for loop.
I tested this in Node 18. It treats it as unhandled. The only way to avoid the gotcha’s described in the article is to ensure all promises: - cannot fail, and/or - are immediately awaited (by anything but .catch is the easiest).
If I was doing this, I'd have an API endpoint which returns all of the chapters data that I needed for the UX display in a single request. Then, I don't need the loop. The response is all or nothing and easier to surface in the UX.
The next request would presumably just for an individual chapter for all the details of that chapter. Again, no loop.
I know this doesn't answer the question of how to deal with the promises themselves, but more about how to design the system from the bottom up not not need unnecessarily complicated code.
If the number of chapters is less than the sliding window of outstanding requests in the user's browser - the browser will make all the requests basically in parallel. If the number of chapters is very large, they will be batched by the browser automatically - chromium used to be about 6 connections per host, max of 10 total, not sure if those are the current limits.
He's just processing them in a loop as they resolve. The real issue with his code is that the loop isn't the right place to handle a rejection. The right place is right there at the top of the file in the map call.
The loop doesn't need to care about a failed fetch or json parse, those should be handled in the map, where you can do something meaningful about it. The loop doesn't even have the chapterUrl of the failed call.
Maybe in the real world it's file or database reads, off-thread parsing, decompression, etc...
> I know this doesn't answer the question of how to deal with the promises themselves
I'm trying to bring up a bigger picture here.
Generally speaking, you're almost always better off with more light requests than fewer heavy requests.
Assuming this is static content (chapters) there's absolutely zilch you're gaining by fetching the whole block at once instead of chapter by chapter, since you can shove it all behind a CDN anyways.
You're losing a lot of flexibility and responsiveness on slow connections, though.
Not to mention - it may make sense to be doing something like fetching say... 5 chapters at a time, and then fetching the next chapter when the user scrolls to the end of the current chapter (pagination - a sane approach used by anyone who's had to deal with data at scale)
In basically every case though, as long as each chapter is at least a few hundred characters, the extra overhead from the requests is basically negligible, and if they're static - easily cached.
In production code I'm using `.catch(log.rescueError(msg, returnValue))` helper function (or .rescueWarn) - which is very helpful. It'll log error of that severity and return what I'm providing in case of exception.
No, the original rejection is preserved.
for coro in asyncio.as_completed(awaitable_list):
earliest_result = await coro # Can be wrapped with try-block
I think same thing can be achieved with smart use of Promise.race. It's a bit different from the article, that wants to iterate in same order as the input. This can be added, by collecting the results and iterating them again, reinventing Promise.allSettled, i didn't fully understand why op couldn't use that.IMO it should only trigger for unhandled promises that are garbage collected. This way the given example wouldn’t cause a false positive