Error management in nested async function calls

I am developing an Excel add-in using the Office JS API. I have a taskpane which is running code similar to the following:

var failA = true
var failB = true

// In reality this is Excel.run, and it injects a context 
async function run(f) { 
  f()
}

// Simulate a failing async call
async function fail(message, delay) {
    setTimeout(()=>{
    throw new Error(message)
   }, delay)
}

// Simulate a successful async call
async function success(message, delay) {
    setTimeout(delay)
}

async function doA() {
    console.log("Inside A");
  if (failA) {
    console.log("Failing A");
    await fail("Error A", 1000)
  } else {
    success("Success A")
  }
  console.log("Done A")
}

async function doB() {
    console.log("Inside B");
  if (failB) {
    console.log("Failing B");
    await fail("Error B", 1000)
  } else {
    success("Success B")
  }
  console.log("Done B")
}

async function main () {
try {
    // This is how Excel.run is called in all the Office samples
    await run(async ()=>{
    console.log("Start main");
    await doA();
    console.log("Between A and B");
    await doB();
    console.log("Finished");
  })}
catch (error) {
    console.log("ERROR: " + error.message)
  }
}

// Need to await main in an async context. In reality main is run from a button
(async () => await main())()
.as-console-wrapper { min-height: 100%!important; top: 0; }

I would expect the error in doA to bubble up and interrupt further execution of doB. The output should then be:

Start main
Inside A
Failing A
ERROR: Error A

Instead what I get is:

Start main
Inside A
Failing A
Done A
Between A and B
Inside B
Failing B
Done B
Finished

followed by two uncaught exceptions Error A and Error B.

What am I doing wrong? Can I achieve the behavior I expect without wrapping doA and doB separately in try...catch blocks?

>Solution :

There are a few issues:

  1. fail is not simulating a failing async call. It returns an immediately fulfilling promise. The setTimeout callback that will later throw an error, is irrelevant for that promise, since that callback executes from a fresh call stack. Similarly, success is initiating a setTimeout that has no relevance to the promise it returns, which again gets fulfilled immediately.

  2. run is not linking its resolution to the fate of the promise returned by f and thus run returns a promise that fulfills while f is not monitored for errors. If f rejects, run will not have captured it.

  3. If you configure your script to execute success then you need to await it, otherwise its delay will have no effect.

Here is a fix:

var failA = true;
var failB = true;

async function run(f) { 
  // Link to the returned promise, so error handling around 
  //    run() will deal with rejections
  return f(); 
}

// Helper function
const expire = ms => new Promise(resolve => setTimeout(resolve, ms));

async function fail(message, delay) {
  await expire(delay);
  // throw must happen in the execution context of function fail
  throw new Error(message); 
}

async function success(message, delay) {
  // Wait for the delay to expire, otherwise it is useless
  await expire(delay); 
}

async function doA() {
  console.log("Inside A");
  if (failA) {
    console.log("Failing A");
    await fail("Error A", 1000);
  } else {
    await success("Success A"); // Must await it
  }
  console.log("Done A");
}

async function doB() {
    console.log("Inside B");
  if (failB) {
    console.log("Failing B");
    await fail("Error B", 1000);
  } else {
    await success("Success B"); // Must await it
  }
  console.log("Done B");
}

async function main () {
  try {
    await run(async ()=>{
        console.log("Start main");
        await doA();
        console.log("Between A and B");
        await doB();
        console.log("Finished");
    })
  } catch (error) {
    console.log("ERROR: " + error.message);
  }
}

main();

Leave a Reply