3

It's easy to forget to use try/catch in an async function or otherwise fail to catch all possible errors when working with promises. This can cause an endless "await" is the Promise is never resolved nor rejected.

Is there any way (such as via a proxy or altering the promise constructor) to cause an async function or other promises to be rejected if there is an uncaught error? The following shows a generalized case. I'm looking for some way to get past the "await" (as in "p" should be rejected when the error is thrown) without fixing "badPromise".

async function badPromise() {
    const p = new Promise((res) => {
        delayTimer = setTimeout(() => {
            console.log('running timeout code...');
            if (1 > 0) throw new Error('This is NOT caught!'); // prevents the promise from ever resolving, but may log an error message to the console
            res();
        }, 1000);
    });
    return p;
}

(async () => {
    try {
        console.log('start async');
        await badPromise();
        console.log('Made it to the end'); // never get here
    } catch (e) {
        console.error('Caught the problem...', e); // never get here
    }
})();```

Robert T
  • 173
  • 2
  • 10

4 Answers4

2

Promises already reject in the case of an uncaught synchronous error:

  • in a Promise constructor, for synchronous (thrown) errors

    If an error is thrown in the executor, the promise is rejected.

  • in onFulfilled and onRejected functions, such as in then and catch

    If a handler function: [...] throws an error, the promise returned by then gets rejected with the thrown error as its value.

  • in async functions

    Return Value: A Promise which will be resolved with the value returned by the async function, or rejected with an exception thrown from, or uncaught within, the async function.

Your problem here isn't that Promise doesn't handle uncaught errors, it's fundamentally because your error is asynchronous: As far as the Promise is concerned, its executor function is a successful little function that calls setTimeout. By the time your setTimeout handler runs and fails, it does so with its own stack that is unrelated to the Promise object or its function; nothing related to badPromise or p exists within your setTimeout handler other than the res reference the handler includes via closure. As in the question "Handle error from setTimeout", the techniques for catching errors in setTimeout handlers all involved editing or wrapping the handler, and per the HTML spec for timers step 9.2 there is no opportunity to catch or interject an error case for the invocation of the function passed into setTimeout.

Other than editing badPromise, there's almost nothing you can do.


Alternatives:

  • Modify/overwrite both the Promise constructor and the setTimeout method in sequence, wrapping the Promise constructor's method to save the resolve/reject parameters and then wrapping the global setTimeout method so to wrap the setTimeout handler with the try/catch that invokes the newly-saved reject parameter. Due to the fragility of changing both global services, I strongly advise against any solutions like this.

  • Create a wrapper higher-order function (i.e. function that returns a function) that accepts a rejection callback and wraps the setTimeout call. This is technically an edit to badPromise, but it does encapsulate what's changing. It'd look something like this:

    function rejectOnError(rej, func) {
      return (...args) => {
        try {
          return func(...args);
        } catch (e) {
          rej(e);
        }
      };
    }
    
    async function badPromise() {
      const p = new Promise((res, rej) => {                 // save reject
        delayTimer = setTimeout(rejectOnError(rej, () => {  // to use here
          console.log('running timeout code...');
          if (1 > 0) throw new Error('Now this is caught');
          res();
        }), 1000);
      });
      return p;
    }
      
    badPromise().catch(x => console.error(`outer: ${x}`));
    console.log('bad promise initiated');
    
      
Jeff Bowman
  • 90,959
  • 16
  • 217
  • 251
1

The underlying issue is that timer callbacks run as top level code and the only way to detect errors in them is to listen for global error events. Here's an example of using a global handler to detect such errors, but it has issues which I'll discuss below the code:

"use strict";
let delayTimer; // declare variable
async function badPromise() {
    const p = new Promise((res) => {
        let delayTimer = setTimeout(() => {  // declare variable!!!
            console.log('running timeout code...');
            if (1 > 0) throw new Error('This is NOT caught!'); // prevents the promise from ever resolving, but may log an error message to the console
            res();
        }, 1000);
    });
    return p;
}

(async () => {
    let onerror;
    let errorArgs = null;
    let pError = new Promise( (res, rej)=> {
        onerror = (...args) => rej( args); // error handler rejects pError
        window.addEventListener("error", onerror);
    })
    .catch( args => errorArgs = args);  // Catch handler resolves with error args
     
    // race between badPromise and global error

    await Promise.race( [badPromise(), pError] );
    window.removeEventListener("error", onerror);  // remove global error handler

    console.log("Made it here");
    if( errorArgs) {
        console.log(" but a global error occurred, arguments array: ", errorArgs);
    }

})();

Issues

  • The code was written without caring what is passed to an global error handler added using addEventListener - you may get different arguments if you use window.onerror = errorHandler.
  • The promise race can be won by any error event that bubbles up to window in the example. It need not have been generated in the badPromise() call.
  • If multiple calls to badPromise are active concurrently, trapping global errors won't tell you which badPromise call errored.

Hence badPromise really is bad and needs to be handled with kid gloves. If you seriously cannot fix it you may need to ensure that you only ever have one call to it outstanding, and you are doing nothing else that might generate a global error at the same time. Whether this is possible in your case is not something I can comment on.

Alternative

A more generic alternative may be to start a timer before calling badPromise and use it to time out the pending state of the returned promise;

let timer;
let timeAllowed = 5000;
let timedOut = false;
let timeout = new Promise( res => timer = setTimeout(res, timeAllowed))
.then( timedOut = true);

await Promise.race( [badPromise(), timeout])
clearTimer( timer);
console.log( "timed out: %s", timedOut);



traktor
  • 17,588
  • 4
  • 32
  • 53
  • Even more generic: Just do not run the code in a timer if you can avoid it. (You can't when importing old JavaScript code that uses timers to run code. But otherwise you can.) – Leo Jan 30 '22 at 13:48
  • Thank you for the detailed feedback! It's a tough one, in this case fixing the promise isn't possible as the reason for my question is to help manage coding mistakes (ie, I'm trying to ensure I get more clear feedback if I create something like "badPromise" on accident). – Robert T Feb 12 '22 at 19:16
0

There may be a way to do this, but in your case I think you really want to use the reject function inside your Promise instead of throw. That's really what reject is for.

async function badPromise() {
    const p = new Promise((res, reject) => {
        delayTimer = setTimeout(() => {
            console.log('running timeout code...');
            if (1 > 0) {
              reject('This is NOT caught!');
              return;
            }
            res();
        }, 1000);
    });
    return p;
}

(async () => {
    try {
        console.log('start async');
        await badPromise();
        console.log('Made it to the end'); // never gets here
    } catch (e) {
        console.error('Caught the problem...', e); // should work now
    }
})();
Christian Fritz
  • 20,641
  • 3
  • 42
  • 71
0

Maybe not an answer to what you want, but you could use a pattern like this for setTimeout:

function testErrors() {
  new Promise((resolve, reject) => {
    setTimeout(() => resolve(), 1000);
  }).then(() => {
    throw Error("other bad error!");
  }).catch(err => {
    console.log("Catched", err);
  })
}
Leo
  • 4,136
  • 6
  • 48
  • 72
  • You actually don't understand OP's question, OP doesn't want people to fix the badPromise but try to catch the error inside it. – ikhvjs Jan 26 '22 at 12:19
  • @ikhvjs Ah, thanks. (But please read my answer again.) – Leo Jan 30 '22 at 12:53