Введение

Совершенно внезапно для себя около года назад я начал писать на NodeJS. Не очень хотелось, но выбор был небольшой — нужна кросс-сборка приложения и в браузер и на сервер.

Оказалось, что для многих концепция async/await в JS — сложная тема. И, если в большинстве случаев TypeScript не даст совершить ошибку при работе с async/await, то в этой ситуации код получается корректный (с точки зрения синтаксиса и проверки типов), а программа работает не так, как задумывалось.

Проблема

Проблема возникает при отлове ошибки в async-функциях.

Вопрос

Приведу два примера:

// --- Пример 1
async function something() {
// ...
  return await someOtherAsyncFunction();
// ...
// --- Пример 2
async function something() {
// ...
  return someOtherAsyncFunction();
// ...

Можете попробовать угадать, что не так с этим кодом?

Даже eslint помечает код в примере 1 как некорректный, за что eslint отдельное “спасибо”.

Ответ

Теперь приведу код, запустив который будет понятно, что не так со вторым примером:

async function fail() {
  throw new Error('Ultimate failure');
}

await (async () => {
  try {
    return await fail();
  } catch (error) {
    console.log('The error was caught');
  }
})();

await (async () => {
  try {
    return fail();
  } catch (error) {
    console.log('The error was not caught');
  }
})();

Как следует из кода выше, 'The error was not caught' не будет выведен, потому что во втором случае ошибка не будет поймана.

Объяснение

Чтобы понять, почему так происходит, нужно немного копнуть вглубь async/await (но не слишком).

Async/await — всего лишь обёртка над промисами, которые в свою очередь являются всего лишь обёрткой над колбеками. Сумма этих двух обёрток позволяет писать асинхронный (с колбеками) код в превдо-синхронном стиле (без колбеков).

Async-функции

Объявление функции с async позволяет в теле функции использовать await.

Кроме этого, если результатом async-функции является не промис, async-функция автоматически заворачивает значение в промис (что-то вроде Promise.resolve(value)).
Пример:

async function returnNumber() {
  return 1;
}

console.log(returnNumber());

// Promise { 1 }

И, наконец, если в теле async-функции происходит выбрасывание исключения, это исключение тоже автоматически заворачивается в промис (что-то вроде Promise.reject(exception)).
Пример:

async function fail() {
  throw new Error('Ultimate failure');
}

console.log(fail());

// Promise { <rejected> Error: Ultimate failure }

Await и разворачивание промисов

Await-же занимается разворачиванием промисов, возвращённых из той функции, которая вызывалась с ключевым словом await:

async function returnNumber() {
  return 1;
}

console.log(await returnNumber());

// 1

И, если вернулся промис с reject‘ом, ошибка из этого промиса заново бросается (и затем заново возвращается через Promise.reject, если не будет поймана в try-catch):

async function fail() {
  throw new Error('Ultimate failure');
}

console.log(await fail());

// console.log ничего не выведет, будет только 'Uncaught Error: Ultimate failure'

«Кто виноват?» и «Что делать?»

В результате, если в последнем try-catch (вверх по стеку) не написать await перед потенциально-выбрасывающей-исключение-функцией, промис (с reject-ом) отправится как есть и блок try-catch не перейдёт в секцию catch. В таком случае исключение вместо обработки будет выброшено.

И если в браузере выбрашенное исключение приведёт только к красной строчке в инспекторе, то в случае сервера весь процесс ляжет (если не пользоваться совсем неприличными способами вроде process.on('uncaughtException', (error: Error) => { /* ... */ });) и в лучшем случае будет автоматически перезапущен (потеряв все данные).

Что делать? Стараться возвращать результаты асинхронных функций через await даже тогда, когда это кажется нелогичным внутри try-catch-блоков:

try {
  const result = await fail();
  return result;
} catch (error) {
  logger.log(error);
  return undefined;
}

И всегда вызывать асинхронные функции через await — даже если те возвращают пустой промис (Promise<void>):

await fail();

Понятно, что на разворачивание/заворачивание промисов в таком случае будет тратиться как минимум один лишний цикл event loop, но ценой микроскопического оверхеда можно сильно стабилизировать систему на уровне подхода к коду.

Заключение

Как оказалось, ввиду малых последствий проблемный код пишут в основном те, кто не работал по-настоящему с NodeJS и встречались с JS только в браузере или очень небольшими кусками в NodeJS (без массового использования async/await).

С тех пор как я осознал это, я использую вопрос про обработку ошибок и async/await на собеседованиях, что позволяет сразу понять: пользовался соискатель NodeJS по-настоящему или нет.