1

Please consider the following code:

const race = () => Promise.any([
    // first promise, rejects in 100
    new Promise((resolve, reject) => {
      setTimeout(reject, 100, 1);
    })
    .then(r => (console.log('one resolved with', r), r))
    .catch(err => (console.warn('one rejected with', err), err)),

    // second promise, resolves in 200
    new Promise((resolve, reject) => {
      setTimeout(resolve, 200, 2);
    })
    .then(r => (console.log('two resolved with', r), r))
    .catch(err => (console.warn('two rejected with', err), err))
  ])
  .then(r => console.log('race resolved with', r))
  .catch(err => console.warn('race rejected with', err))

race()

Note For brevity, I used comma expressions. If you prefer a more traditional syntax, here's the long form:

const race = () => Promise.any([
    new Promise((resolve, reject) => {
      setTimeout(reject, 100, 1);
    })
    .then(r => {
      console.log('one resolved with', r);
      return r;
    })
    .catch(err => {
      console.warn('one rejected with', err);
      return err;
    }),
    new Promise((resolve, reject) => {
      setTimeout(resolve, 200, 2);
    })
    .then(r => {
      console.log('two resolved with', r);
      return r;
    })
    .catch(err => {
      console.warn('two rejected with', err);
      return err;
    })
  ])
  .then(r => console.log('race resolved with', r))
  .catch(err => console.warn('race rejected with', err))

race()

I'm expecting race() to resolve with 2, in 200. Instead, it resolves with the return value of the first promise's catch handler (1), in 100, (even if it's not explicit - e.g: If my catch wouldn't actually return the error. The .any() wrapper would still resolve with undefined, instead of waiting for the first fulfilled promise. Resolve, not even reject!).

What's even funnier is that the expected behavior does happen if I do not specify a catch handler in the failing promise. Have a look:

const race = () => Promise.any([

    new Promise((resolve, reject) => {
      setTimeout(reject, 100, 1);
    })
    .then(r => (console.log('one resolved with', r), r)),

    new Promise((resolve, reject) => {
      setTimeout(resolve, 200, 2);
    })
    .then(r => (console.log('two resolved with', r), r))

  ])
  .then(r => console.log('race resolved with', r))
  .catch(err => console.warn('race rejected with', err))

race()

Maybe I want to log failures! Why does having some code run on rejection make the parent Promise.any() resolve with return value of the first failed promise which happens to have a .catch handler, instead of waiting for the first fulfilled promise, as advertised?

Shouldn't the parent Promise.any() have the same behavior regardless of whether the catch callback is defined or not?

Is this a bug? I find it mind boggling.


Update: after receiving the explanation on why this happens, I managed to obtain what I was initially looking for (logging of errors in catch blocks, while still waiting for the first of the other promises to fulfill), by returning Promise.reject(err) in the catch handler:

const race = () => Promise.any([
    // first promise, rejects in 100
    new Promise((resolve, reject) => {
      setTimeout(reject, 100, 1);
    })
    .then(r => (console.log('one resolved with', r), r))
    .catch(err => (console.warn('one rejected with', err), Promise.reject(err))),

    // second promise, resolves in 200
    new Promise((resolve, reject) => {
      setTimeout(resolve, 200, 2);
    })
    .then(r => (console.log('two resolved with', r), r))
    .catch(err => (console.warn('two rejected with', err), Promise.reject(err)))
  ])
  .then(r => console.log('race resolved with', r))
  .catch(err => console.warn('race rejected with', err))

race()
tao
  • 82,996
  • 16
  • 114
  • 150

1 Answers1

2

When you chain a .catch onto a Promise, the whole expression will evaluate to a resolved Promise, that evaluates to the value returned at the end of the .catch. See how the below does not result in the second .catch being entered into.

Promise.reject()
  .catch((err) => { console.log('first catch'); })
  .catch((err) => { console.log('second catch'); })

// In other words, the whole below expression resolves to a resolved Promise:
//   Promise.reject()
//     .catch((err) => { console.log('first catch'); })

For the whole

somePromise
  .catch(hander)

to result in a rejected Promise, the handler would have to throw (or construct and return a rejected Promise).

So, in your original code:

.catch(err => (console.warn('one rejected with', err), err)),

This makes that whole expression resolve. If you want it to reject, change it to:

.catch(err => {
  console.warn('one rejected with', err);
  throw err;
}),

(throw needs to be a standalone statement, so it can't be used with the comma operator)

const race = () => Promise.any([
    // first promise, rejects in 100
    new Promise((resolve, reject) => {
      setTimeout(reject, 100, 1);
    })
    .then(r => (console.log('one resolved with', r), r))
    .catch(err => {
      console.warn('one rejected with', err);
      throw err;
    }),

    // second promise, resolves in 200
    new Promise((resolve, reject) => {
      setTimeout(resolve, 200, 2);
    })
    .then(r => (console.log('two resolved with', r), r))
    .catch(err => (console.warn('two rejected with', err), err))
  ])
  .then(r => console.log('race resolved with', r))
  .catch(err => console.warn('race rejected with', err))

race()

A common approach to a similar issue is to just not .catch unless you can do something useful with the error - and if not, just let the rejected Promise percolate back up to its caller, so that its caller can take care of it. While you can .catch and re-throw, it's a tiny bit ugly, so you probably don't want to do it unless necessary.

CertainPerformance
  • 356,069
  • 52
  • 309
  • 320
  • Ok, so attaching a `.catch()` not only runs that code when the promise fails, but it also guarantees the promise will always resolve, with the catch's return as backup, if it actually got rejected. That's not intuitive. At all. Thanks for clarifying. – tao Jan 17 '22 at 18:30
  • Yep. The original Promise will still reject, but the new Promise (created from `theProm.catch(handler)`) will resolve. – CertainPerformance Jan 17 '22 at 18:32
  • Ok, I got it working as I was expecting by returning a promise rejecting with the received value: (e.g: `.catch(err => (console.log(err), Promise.reject(err)))`. This logs and still makes the parent `.any()` wait for first one to fulfill (or all to `reject` in which case it aggregates all errors). – tao Jan 17 '22 at 18:57
  • 1
    @tao "*so attaching a `.catch()` not only runs that code when the promise fails, but it also guarantees the promise will always resolve, with the catch's return as backup, if it actually got rejected.*" - no. Just like `.then()`, calling `.catch()` returns a *new promise*, and just like for `then`, that new promise is resolved with the result of the callback when called. That result might be another promise, a return value, or an exception. – Bergi Jan 17 '22 at 21:14