Search code examples
javascripttypescriptasync-await

Looking for guidance on using await on intermediate asynchronous calls to functions that could throw errors


I have a webhook that's making a call to an async function (A) that then calls another async function (B) that makes asynchronous database calls (C1, C2, etc.).

In the webhook, it knows something could go wrong, and so it uses the await keyword when calling function A and a try/catch block, and then it can handle any exception without crashing the process.

However, it doesn't work. Before all the work is done, function A returns and the webhook call completes. Later, an exception happens in function B or below, which is unhandled and crashes the whole process.

It turns out that function A doesn't have an await. When await is added to function A, everything works as expected and the top level webhook catches the exception and handles it without crashing.

I'm relatively new to asynchronous Javascript coding, but this is not what I originally expected.

If you want to completely await a function call, then all the asynchronous function calls anywhere below that also have to have await? It seems to me that it would be common to write some code that doesn't need the await and so doesn't include it. And it seems weird that you would have to open up that code later if some new higher level call wanted to await. Also, when using a third party library, this is a detail I wouldn't want to need to be at the mercy of — if I want to await their asynchronous code finishing, I want to be able to whether or not they thought of that.

So my problem isn't getting it to work, I figured that out. I think I also even understand why — the intermediate function effectively throws it's call into the queue and immediately returns.

But does that mean a best practice when writing asynchronous functions that call other asynchronous functions is to always include the await on asynchronous calls that you think the parent might want to handle the errors from? Any other related best practice hints?

The code below is a simplified recreation of the situation.

(And I know there are many, many similar questions, but I couldn't find one that matched this. The others seemed all related to forgetting the outer await or for loops. Apologies if I missed the match.)

TypeScript Playground link

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

async function sleepAndThrow() {
  await sleep(1000);
  throw new Error("Error from sleepAndThrow()");
}

async function sleepAndThrowWrapperWithoutAwait() {
  sleepAndThrow();
}

async function sleepAndThrowWrapperWithAwait() {
  await sleepAndThrow();
}

async function main() {
  console.log("Running main()...");
  try {
    // if the following is replaced with sleepAndThrowWrapperWithAwait(), it works as expected
    await sleepAndThrowWrapperWithoutAwait();
    console.log("Didn't expect to get here");
  } catch (e) {
    console.error(e);
  }
}

main();

The code unexpectedly (to me) runs console.log("Didn't expect to get here");


Solution

  • I think the answer I was looking for is that there are three options when calling an async function:

    1. await the promise (and choose whether you want to handle errors at this level or let them bubble up)
    2. return the promise to the next level up (which will bubble any errors up)
    3. accept that any errors not handled by the called async function will happen outside of context of the initial request (which will crash your process and is probably always a bad idea)

    It would be reasonable to go as far as to say the promise from every async function call should always be either awaited or returned.

    In my case, since there's no reason for awaiting in the middle function, going with option #2 and returning the promise seems to most clearly express what's happening (and there's an ever so minor benefit of not creating an intermediate promise).

    async function sleepAndThrowWrapper() {
      return sleepAndThrow();
    }
    

    I found this answer to be the most helpful in explaining the differences between option #1 and option #2. The accepted answer to this question covers why #3 is a bad idea.

    Conceptually, when thinking about awaiting promises, it's not just about whether their return values are needed by later code, it's also about their errors being needed by later code.

    Much more subtly, if a function returns the value from another asynchronous function call (or both the function and the call return void), and the return value from the call doesn't need processed before being returned, then a way to express that is to return the call's promise rather than await it.