2

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.

gschro
  • 21
  • 3
  • "*the asynchronous case, which admittedly involves another Promise*" - yeah, [don't do that](https://stackoverflow.com/q/23803743/1048572?What-is-the-promise-construction-antipattern-and-how-to-avoid-it)! – Bergi Mar 19 '23 at 06:31
  • "*cases of thrown errors and exceptions (code bugs)*" - there's no difference between these really – Bergi Mar 19 '23 at 06:32
  • Sounds like https://stackoverflow.com/questions/33445415/javascript-promises-reject-vs-throw would answer your question? – Bergi Mar 19 '23 at 06:34

2 Answers2

3

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.

jfriend00
  • 683,504
  • 96
  • 985
  • 979
  • Thanks for your very complete answer. You mention I should not be using fn().then() in an executor, and so I would like to understand how to structure my code. I've read quite a bit about promises but the articles don't seem to go into these higher level design questions - if you have a reference that would be great. My question is how I should create functions (that will return a promise) and in those functions, use lower level functions that themselves return promises. It seems like a natural thing to do. Maybe I am missing something obvious. – gschro Mar 19 '23 at 05:11
  • @gschro - You just return the inner promise from the containing function. No need to do `new Promise()`. Tune into the `promise` tag here. read multiple questions a day, attempt to come up with answers to some of them and you'll learn and see a lot. – jfriend00 Mar 19 '23 at 05:47
1

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)
    }
Matt
  • 68,711
  • 7
  • 155
  • 158
  • Thanks for your reply (it seems I am too new to upvote it). I am having a hard time accepting the recommendation that you shouldn't use fn.then() in an executor. It seems contrary to the fundamental notion in computer science of building more higher level functions out of simpler ones. If I work in a world where many functions return promises, that recommendation would seem very limiting. Unfortunately, I have not been able to find or think up a solution. I think the key is to always have a catch, and not depend on any implicit try...catch that promises might provide. – gschro Mar 19 '23 at 18:06
  • Read that advice as "you shouldn't use fn.then() in a new Promise". The concept is fine in a function, just the wrapping of promises in promises is unnecessary. That complexity is largely hidden by using `async`/`await` anyway, so just do that, would be my advice – Matt Mar 20 '23 at 06:29