0

Edit: in summary this issue is regarding unhandledRejection in node.js. I didn't expect a promise that was not awaited (but later fails) to crash the program - because I didn't realize that unhandledRejection was being thrown & causing node.js to exit. Refreshing my understanding of unhandledRejection I now realize that any promise anywhere in the program that is rejected without a catch() statement will throw unhandledRejection and exit the node.js process on default. Which doesn't make too much sense to me, but at least I understand why it's happening.

In the following code example, thisFailsLater initially returns successfully, but then throws an error some time later.

This results in runTest() passing the try/catch and moving on to the next await.

However, while waiting for the next await call, thisFailsLater then throws an error, which causes Node.JS to exit:

% node t.js 
Throwing error in 2 seconds
thisFailsLater returned successfully
t.js:4
  throw new Error('thrown error in thisFails()')
        ^

Error: thrown error in thisFails()
    at thisFails (t.js:4:9)

This is something I didn't expect: it seems that an async function can initially return successfully, but then any async function called but not awaited within the function that throws after returning will then crash the Node.JS process.

Am I correct in how this works? And if so, how can I prevent Node.JS from exiting when thisFailsLater() throws 2 seconds after returning successfully? This completely breaks my understanding of how Promises work (can only be resolved or errored once).

Error reproduction:

async function thisFails() {
  console.log('Throwing error in 2 seconds')
  await new Promise((resolve) => setTimeout(resolve, 2e3));
  throw new Error('thrown error in thisFails()')
}

async function thisFailsLater() {
  try {
    // NOTE: intentionally NOT awaiting this call here.
    thisFails()
  } catch (err) {
    console.log('thisFailsLater caught error', err)
  }
}

async function runTest() {
  try {
    await thisFailsLater()
  } catch (err) {
    console.error('runTest caught error', err)
  }
  console.log('thisFailsLater returned successfully')
  await new Promise((resolve) => setTimeout(resolve, 3e3));
}

runTest().then(() => {
  process.exit(0)
}).catch((err) => {
  // NOTE: the error that Node.JS crashes with is NOT logged here!
  console.error('Caught error in main()', err)
  process.exit(1)
})
Christian Stewart
  • 15,217
  • 20
  • 82
  • 139
  • "*This completely breaks my understanding of how Promises work (can only be resolved or errored once).*" no, your understanding is correct. `thisFailsLater()` only succeeds despite the misleading name. While `thisFails` will indeed fail. Each of them only once. The try/catch does nothing without an `await` that goes with the promise. So, deliberately omitting it makes very little sense. – VLAZ Dec 13 '22 at 21:31
  • @VLAZ The example is crafted to specifically show a case that I'm running into in a different bit of code: an await returns successfully, but then something throws an error later, resulting in the node.js process exiting. – Christian Stewart Dec 13 '22 at 21:37
  • 1
    The whole try/catch is a red herring then. All you have is a function that fires and forgets an async task. It then exists successfully. The async task fails. There is no error handling there. – VLAZ Dec 13 '22 at 21:40
  • @VLAZ Correct, the question is about why the program was exiting when the async task fails. The reason was unhandledRejection. In the actual code I'm trying to debug it's very hard to find where the unhandledRejection is coming from. Please understand that the code example is crafted in a slightly illogical way on purpose to reproduce an issue I'm having with some far more complex code in another project on GitHub - https://github.com/aperturerobotics/starpc – Christian Stewart Dec 13 '22 at 21:45

2 Answers2

3

The problem occurs here:

async function thisFailsLater() {
  try {
    // NOTE: intentionally NOT awaiting this call here.
    thisFails()
  } catch (err) {
    console.log('thisFailsLater caught error', err)
  }
}

When you don't await the promise (which rejects), you fail to handle the rejection. One of the things that the await keyword does is "convert" a rejected promise into an exception in the async context — from the MDN documentation for await:

If the promise is rejected, the await expression throws the rejected value. The function containing the await expression will appear in the stack trace of the error. Otherwise, if the rejected promise is not awaited or is immediately returned, the caller function will not appear in the stack trace.

Because you don't await the rejected promise, it can't be caught in your try block, converted to an exception, and passed as the argument to the catch clause during the transition of control flow. If you don't await the promise, but want to handle the rejection, you must use the .catch() method promise syntax:

function thisFailsLater() {
  return thisFails().catch(exception => console.log('thisFailsLater caught error', exception));
}
jsejcksn
  • 27,667
  • 4
  • 38
  • 62
  • The answer was the `unhandledRejection` error. Your answer is good too, thanks. I gave the check to Bergi since they specifically pointed me to unhandledRejection - apologies for my confusion on that point. – Christian Stewart Dec 13 '22 at 21:47
  • This is why I like Go's error handling model so much more. Errors are just normal variables and you return them from a function as a return value. So you always can predict exactly what will happen with them and ignore them if you want. Throwing exceptions that blow everything up if you don't catch them, and never being sure if your code is prone to exploding that way or not, seems illogical in comparison. – Christian Stewart Dec 13 '22 at 22:03
  • @ChristianStewart You might be interested in studying and using monadic return types in your programs. Here's a minimal TypeScript library you can read over as an intro if you're not already familiar: [GitHub: sniptt-official/monads](https://github.com/sniptt-official/monads) – jsejcksn Dec 13 '22 at 22:09
  • Thanks, will check it out! In this case I got tripped up by calling end(new Error()) on a async-iterable with `it-pushable` and am not sure how to fix that with monadic return types. But will definitely have a look at the link. – Christian Stewart Dec 13 '22 at 22:12
3

It seems that an async function can initially return successfully, but then any async function called but not awaited within the function that throws after returning will then crash the Node.JS process.

Am I correct in how this works?

Yes. But this is not unique to async functions - any function can do that in node.js:

function failLater() {
    setTimeout(() => {
        throw new Error("crash!");
    }, 1000);
    return "success"
}

And if so, how can I prevent Node.JS from exiting when thisFailsLater() throws 2 seconds after returning successfully?

Just don't do that.™

Do not let functions cause exceptions asynchronously.

To prevent nodejs from exiting, you can hook on the global error and unhandledrejection events, but those are meant for logging - your application already has suffered from an unrecoverable error.

This completely breaks my understanding of how Promises work (can only be resolved or errored once).

It's not related to promises, the promise does settle only once, when the body of the async function concludes evaluating. The thisFails() is a second, separate promise - independent from the flow of execution since you didn't await it.

Bergi
  • 630,263
  • 148
  • 957
  • 1,375
  • 1
    Not necessarily due to `setTimeout`, you can also make it crash with just an unawaited `Promise.reject(new Error('crash'));` - but the issue is basically the same, the mechanics of [asynchronous exceptions](https://stackoverflow.com/a/21617531/1048572), yes. – Bergi Dec 13 '22 at 21:47
  • Ah - the unhandledRejection - something in my code is causing unhandledRejection to happen, and I didn't realize that was the source of Node.JS logging & exiting (since it doesn't say it anywhere in the message node outputs). Thanks for pointing me to `process.on('unhandledRejection', ...` - that's a good place for me to start to try to figure out where the unhandled rejection is coming from. – Christian Stewart Dec 13 '22 at 21:54