skip to Main Content

I am trying to understand promise error handling for the cases of thrown errors and exceptions (code bugs) in the promise executor, comparing them to reject. This is for node.js.

It seems like there are clear differences in the handling of reject vs throw and exceptions.

Some web articles imply there is an implicit try/catch around promises that converts exceptions and throws to rejects, but apparently that is not true, or not always true.

I wrote some test code to study it. That code and the results are below.

Test code:

function delay(ms) {
    return new Promise((resolve, reject) => {
        setTimeout(value => {
            resolve();
        }, ms);
    });
}

function f(delayMs, action) {
    return new Promise((resolve, reject) => {
        if (delayMs == 0) {
            console.log(`f() forcing error type ${action} sync...`);
            if (action == 'exception') {
                console.xxx();
            } else if (action == 'throw') {
                throw new Error('thown error sync');
            } else {
                reject('reject sync');
            }
        } else {
            delay(delayMs).
                then(() => {
                    console.log(`f() forcing error type ${action} after delay...`);
                    if (action == 'exception') {
                        console.yyy();
                    } else if (action == 'throw') {
                        throw new Error('thrown error after delay');
                    } else  {
                        reject('reject after delay');
                    }
                });
        }
    });
}

function main(delayMs, action) {
    f(delayMs, action).
        then(value => {
            console.log('Should not see this');
        }).
        catch(err => {
            if (typeof err == 'object') console.log('In main catch:', err.message);
            else console.log('In main catch:', err);
        }).
        finally(() => {
            console.log('In main finally');
        });
}

main(0, 'exception');
main(0, 'throw');
main(0, 'reject');
main(500, 'reject');
main(1000, 'exception');
main(1500, 'throw');

Output:

f() forcing error type exception sync...
f() forcing error type throw sync...
f() forcing error type reject sync...
In main catch: console.xxx is not a function
In main catch: thown error sync
In main catch: reject sync
In main finally
In main finally
In main finally
f() forcing error type reject after delay...
In main catch: reject after delay
In main finally
f() forcing error type exception after delay...
/mnt/c/Users/test/promise-stackoverflow-2.js:25
                        console.yyy();
                                ^

TypeError: console.yyy is not a function
    at /mnt/c/Users/test/promise-stackoverflow-2.js:25:33

Node.js v18.12.1

For the synchronous case, all three error types are handled the same, being caught in the catch in the main function.

For the asynchronous case, which admittedly involves another Promise, the reject case is still handled in the catch in the main function. But the other two cases (exception and throw) cause a higher level exception and the process exits.

It looks like I can add a catch handler for the delay() promise, and then do a reject in that catch and it will propagate to the main catch handler. I didn’t think I would need to do this – am I missing something?

Also, some articles imply that error handling is easier using async/await. To me, it seemed like if you want to do strict error handling, just using promises was easier.

2

Answers


  1. When you do delay().then() and then throw inside the .then(), that throw is caught by the .then() and turned into a rejected promise. That throw does not make it to the try/catch of the executor. Then, you don’t have any code to catch or handle that rejection and thus you get an unhandled rejection.

    There is an automatic try/catch around the promise executor, but that only catches thrown exceptions at the top level of that function, not unhandled promise rejections.

    It’s not really instructive to discuss how to fix this structure of code because you really shouldn’t be doing fn().then() inside a promise executor anyway. You are manually wrapping a promise with another unnecessary promise at that point. The outer, manually created promise would be unnecessary.

    It looks like I can add a catch handler for the delay() promise, and then do a reject in that catch and it will propagate to the main catch handler. I didn’t think I would need to do this – am I missing something?

    Yes, that will add the missing reject handler. But, this shouldn’t normally happen in real world coding because you shouldn’t be doing fn().then() inside a promise executor in the first place.

    Also, some articles imply that error handling is easier using async/await. To me, it seemed like if you want to do strict error handling, just using promises was easier.

    With some experience, you will find that coding with promises, in general, is simpler with async/await, particularly when you get to more complicated scenarios such as multiple sequenced asynchronous operations and branching of asynchronous operations. A single chain
    of operations can be done easily either way.

    Error handling is sometimes simpler with async/await and sometimes not. It really depends upon the situation and how local/global you want to handle the error. Handling a rejection locally (directly on the immediate promise) is sometimes easier with .catch() whereas centralizing reject handling, particularly when there are multiple promise chains involved may be a lot simpler with async/await and try/catch. It really depends upon the layout of the code and where you need to catch/handle the error.

    Plus, the automatic catching of synchronous exceptions and auto-turning them into rejections can be useful in async functions too so you don’t have to code to handle both synchronous exceptions and rejected promises in cases where either could occur.

    Login or Signup to reply.
  2. The call to delay() has broken the promise chain. The only thing tying the promise created by delay back to the parent is the closure on reject from the parent promise scope.

            } else {
                // This promise is not available to `main`
                delay(delayMs).
                    then(() => {
                        console.log(`f() forcing error type ${action} after delay...`);
                        if (action == 'exception') {
                            console.yyy();
                        } else if (action == 'throw') {
                            throw new Error('thrown error after delay');
                        } else  {
                            // This is the only path that affects `main()` catch handler
                            reject('reject after delay');
                        }
                    });
            }
    

    As jfriend mentioned, this isn’t really how you would want to write promise code but to demonstrate, the promise would need to be resolved from f to be handled by main

        } else {
            // Keep a reference to the promise
            const p = delay(delayMs).
                then(() => {
                    console.log(`f() forcing error type ${action} after delay...`);
                    if (action == 'exception') {
                        console.yyy();
                    } else if (action == 'throw') {
                        throw new Error('thrown error after delay');
                    } else  {
                        // This reject would just be a throw now this
                        // promise is resolved
                        reject('reject after delay');
                    }
                });
            resolve(p)
        }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search