How to handle an unhandled promise rejection asynchronously?

11,661

Solution 1

The detection of unhandled rejection in node.js is imperfect. There are specific spots in the life cycle of a rejected promise where the engine checks to see if there's a handler and it does not always wait until the last possible moment so it can miss places that you add a handler. In your specific case, you may need to attach a .catch() handler locally, then finish up the work you want to do, then rethrow the error. This work-around will work for you while still maintaining the desired resolve/reject from main() (e.g. without changing the interface to main).

So, this isn't particularly super pretty, but it meets the spec we talked about in comments.

  1. main() calls fetchMock()
  2. If it resolves or rejects quickly (before some custom delay time), then it holds off on the resolve or the reject until at least that delay time has elapsed from when fetchMock() was originally called.
  3. If fetchMock() takes longer than that custom delay time to resolve or reject, then no further delay is added.
  4. The promise that main() returns then follows the promise that fetchMock() returned, either rejected or resolved with the same reason or value.

The key ingredient is that it captures the time right before calling fetchMock() and then when fetchMock() either resolves or rejects, it decides whether to delay any more time before passing the resolve/reject value/reason on through.

function sleep(ms) {
    return new Promise(resolve => {
        setTimeout(resolve, ms);
    });
}

function fetchMock() {
    return new Promise((resolve) => {
        throw 'error fetching result';
        //resolve('this is our result');
    });
}


function handler(start, minWaitTime, isErr = false) {
    return async function(val) {
        let diff = minWaitTime - (Date.now() - start);
        if (diff > 0) {
            await sleep(diff);
        }
        if (isErr) {
            throw val;
        } else {
            return val;
        }
    }
}

function main() {
    let start = Date.now();
    const minWaitTime = 1000;
    return fetchMock().then(handler(start, minWaitTime), handler(start, minWaitTime, true));
}

main()
    .then(() => console.log('resolved promise!'))
    .catch(error => console.error('caught error!', error));

Note, also that sleep() and fetchMock() already directly return promises and don't use await so there is no requirement for them to be async.

Solution 2

The original concept of promises was that you could have a rejected promise sitting around for some time before attaching a catch handler to it. For example, Firefox used to warn of uncaught rejection errors only when a rejected promise with no rejection handler was garbage collected from memory.

Somebody decided that programmers couldn't be trusted with managing promise rejections properly and changed the HTML spec to require browsers to throw "unhandled promise rejection" errors if a rejected promise has no rejection handlers added before code returns to the event loop.

(I think unhandled rejections can survive without error in the micro task queue for a tick or two, before control returns to the event loop proper, but haven't tested it lately.)

The ECMAScript specification added an abstract means of notifying the host environment of an unhandled promise rejection without specifying what, if any, action should be taken.

On a case by case basis you can prevent the host being notified by adding a rejection handler that is never used. The reasoning is that adding a dummy rejection handler to a promise means that should it be rejected it has a rejection handler already - or if it was rejected the host is notified the promise now has a rejection handler - and you can call then and catch multiple times on the same promise.

Changing

async function fetchMock(){
  return new Promise(() => {
    throw 'error fetching result';
  });
}

to

async function fetchMock(){
  let promise = new Promise(() => {
    throw 'error fetching result';
  });
  promise.catch(()=>null); // unused rejection handler
  return promise;
}

should work around the unwanted HTML5 host behavior implemented in V8, the JavaScript engine used in node.

Solution 3

The problem is that the fetchMock rejects immediately, and when a Promise rejects, at the time that it rejects, it must be chained with a .catch somewhere in order to prevent an Unhandled Promise Rejection.

With your await sleep(10), the kickedOffRequest promise rejects while the main function is still waiting for sleep to resolve. When there's a rejection, the interpreter doesn't look ahead to see if the Promise may be caught in the future (for example, to see if the Promise gets returned or caught) - the Promise must be caught now.

When you remove the await, kickedOffRequest still becomes a rejected Promise, but it's returned from main immediately, so at the point that the Promise rejects, it can be seen and caught by the outer .catch, so there's no Unhandled Rejection warning.


It's good if a Promise rejection can be handled right when it rejects, but if that's not an option for you, you can put another .catch onto the end of the inner Promise. That way, the warning won't appear (because of the existence of .catch, even if that .catch didn't do anything meaningful) and you can later check to see if an error actually occurred or not:

async function sleep(ms) {
  return new Promise(resolve => {
    setTimeout(resolve, ms);
  });
}

async function fetchMock(){
  return new Promise(() => {
    throw 'error fetching result';
  });
}

async function main() {
  const kickedOffRequest = fetchMock()
    .then(resolved => ({ resolved }))
    .catch(rejected => ({ rejected }));
  await sleep(10);
  return kickedOffRequest;
}

main()
  .then(({ resolved, rejected }) => {
    if (resolved) {
      console.log('resolved promise!');
    } else {
      console.error('caught error!', rejected);
    }
  });

This is pretty similar to how Promise.allSettled works (though it's more intended for when there's an array of Promises that need to be parsed):

async function sleep(ms) {
  return new Promise(resolve => {
    setTimeout(resolve, ms);
  });
}

async function fetchMock(){
  return new Promise(() => {
    throw 'error fetching result';
  });
}

async function main() {
  const kickedOffRequestArr = Promise.allSettled([fetchMock()]);
  await sleep(10);
  return kickedOffRequestArr;
}

main()
  .then(([{ result, reason, value }]) => {
    if (result === 'fulfilled') {
      console.log('resolved promise!', value);
    } else {
      console.error('caught error!', reason);
    }
  });
Share:
11,661

Related videos on Youtube

marhaupe
Author by

marhaupe

Updated on September 16, 2022

Comments

  • marhaupe
    marhaupe over 1 year

    I'm trying to wrap my head around this issue I'm facing concerning async/await and Promises. I managed to boil my issue down to the following code:

    async function sleep(ms: number) {
      return new Promise(resolve => {
        setTimeout(resolve, ms);
      });
    }
    
    async function fetchMock(): Promise<any> {
      return new Promise(() => {
        throw 'error fetching result';
      });
    }
    
    async function main(): Promise<any> {
      const kickedOffRequest = fetchMock();
      await sleep(10);
      return kickedOffRequest;
    }
    
    main()
      .then(() => console.log('resolved promise!'))
      .catch(error => console.error('caught error!', error));
    

    I receive the following warning:

    (node:82245) UnhandledPromiseRejectionWarning: error fetching result
    (node:82245) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). (rejection id: 1)
    (node:82245) [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.
    caught error! error fetching result
    (node:82245) PromiseRejectionHandledWarning: Promise rejection was handled asynchronously (rejection id: 1)
    

    You can observe the same issue in this sandbox. I noticed that commenting out the await sleep(10) fixes the issue, but I apparently know less about promises than I thought. Why does commenting that line out make my program work? I'm tempted to ask how to fix the Promise rejection was handled asynchronously error, but I hope that once I understand how await sleep(10) causes the error I get I will be able to fix this one on my own.

    Thanks in advance for taking the time to read/answer this question!

    • jfriend00
      jfriend00 over 4 years
      @Bergi - I don't see how the duplicate you marked actually provides a solution to this specific problem. What the OP appears to want in main() is to return the promise from fetchMock() whether rejected or resolved, but not have it resolve or reject until at least 10ms have passed. I don't see how to do that in your dup. So, if fetchMock() resolves or reject immediately, then the promise returned from main() wouldn't resolve or reject for 10ms. But, if the promise from fetchMock() took more than 10ms to resolve or reject, no further delay would be added to that resolve or reject.
    • jfriend00
      jfriend00 over 4 years
      Well, there's no obvious use of Promise.all() that creates the exact timing that main() shows. Canonical answers work fine for canonical questions, not questions that raise a different variant for which a solution is not shown in the canonical answer.
  • marhaupe
    marhaupe over 4 years
    Thank you! That clears things up. I'm not sure how I feel about returning a Promise that resolves to a "wrapper" around an error or an actual response. I liked just throwing the error in a nested function and making the user of main react to the error, but if I understood correctly, that's not really possible, is it?
  • CertainPerformance
    CertainPerformance over 4 years
    Right. It's possible, but only barely - it's not a good idea due to the rejection warning, which will eventually become an error by Node (at which point it will be actually impossible).
  • jfriend00
    jfriend00 over 4 years
    @marcelHaupenthal - OK, here's a way to do it.
  • marhaupe
    marhaupe over 4 years
    You're awesome! Thanks for taking your time for me. I think this is the solution I was looking for. I'm going to have to read some more documentation about promises, but this helps me alot! Have a great day.