Handling unhandled promise rejections in async functions

I am developing a HTTP proxy service and I have observed presence of an odd error message in my logs:

unhandledRejection RequestError: HTTP request error.
at /dev/rayroute/raygun/src/factories/createRequest.js:107:13
at processTicksAndRejections (internal/process/task_queues.js:93:5) {
  code: 'RAYGUN_REQUEST_ERROR',
  originalError: Error: test
  at /dev/rayroute/raygun/src/factories/createRequest.js:73:29
  at processTicksAndRejections (internal/process/task_queues.js:93:5)

It is odd because there are plethora of tests to ensure that all errors are handled. It is also odd because I have never never seen unhandled rejection while developing the service (only ever saw it in production logs).

The relevant code looks like this:

const activeRequestHandler = createRequest(requestDefinition);
 
if (incomingMessage.socket) {
  incomingMessage.socket.on("close", () => {
    if (responseIsReceived) {
      log.trace("client disconnected after response");
    } else {
      log.debug("client disconnected");
 
      activeRequestHandler.abort(new Error("CLIENT_DISCONNECTED"));
    }
  });
}
 
try {
  await actions.afterRequestActions(context, requestDefinition, activeRequestHandler);
} catch (error) {
  log.error(
    {
      error: serializeError(error),
    },
    "afterRequest threw an error",
  );
}
 
try {
  responseDefinition = await activeRequestHandler.response;
} catch (error) {
  log.warn(
    {
      error: serializeError(error),
    },
    "an error occurred while waiting for a HTTP response",
  );
 
  // [..]
}

It is pretty straightforward:

  • createRequest initiates a HTTP request and returns a request handler
  • the request handler can be used to abort the ongoing request (afterRequestActions aborts request after a hard-timeout); and
  • it is used to resolve the response or eventual rejection of the promise

I have written tests to ensure that errors are handled when:

  • request rejected
  • request aborted
  • afterRequestActions throws an error

, but all tests are passing.

It turns out that the problem was that in all my test cases actions.afterRequestActions was resolving/ being rejected before activeRequestHandler.response is resolved. Meanwhile, in production afterRequestActions contains logic that can take substantially longer to execute. I have also learned that even if you declare a try..catch block for your async function, if it resolves before it is await-ted, then you will get an unhandled rejection, i.e.

This code will not warn about unhandled rejection:

const delay = require("delay");
 
const main = async () => {
  const promise = new Promise((resolve, reject) => {
    setTimeout(() => {
      reject(new Error("Expected rejection."));
    }, 100);
  });
 
  await delay(90);
 
  try {
    await promise;
  } catch (error) {
    console.error(error);
  }
};
 
main();

But this code will always produce a warning about an unhandled rejection:

const delay = require("delay");
 
const main = async () => {
  const promise = new Promise((resolve, reject) => {
    setTimeout(() => {
      reject(new Error("Expected rejection."));
    }, 100);
  });
 
  await delay(110);
 
  try {
    await promise;
  } catch (error) {
    console.error(error);
  }
};
 
main();

The best solution is to add an auxiliary catch block, e.g. This is how I refactored my original code:

const activeRequestHandler = createRequest(requestDefinition);
 
// Without this we were getting occasional unhandledRejection errors.
// @see https://dev.to/gajus/handling-unhandled-promise-rejections-in-async-functions-5b2b
activeRequestHandler.response.catch((error) => {
  log.warn(
    {
      error: serializeError(error),
    },
    "an error occurred while waiting for a HTTP response (early warning)",
  );
});
 
if (incomingMessage.socket) {
  incomingMessage.socket.on("close", () => {
    if (responseIsReceived) {
      log.trace("client disconnected after response");
    } else {
      log.debug("client disconnected");
 
      activeRequestHandler.abort(new Error("CLIENT_DISCONNECTED"));
    }
  });
}
 
try {
  await actions.afterRequestActions(context, requestDefinition, activeRequestHandler);
} catch (error) {
  log.error(
    {
      error: serializeError(error),
    },
    "afterRequest threw an error",
  );
}
 
try {
  responseDefinition = await activeRequestHandler.response;
} catch (error) {
  log.warn(
    {
      error: serializeError(error),
    },
    "an error occurred while waiting for a HTTP response",
  );
 
  // [..]
}