Эта история изначально была опубликована здесь.

Создание надежных приложений Node.js требует правильной обработки ошибок. Это третья статья из серии, цель которой — дать обзор того, как обрабатывать ошибки в асинхронных сценариях Node.js.

Обработка ошибок в асинхронных сценариях

В предыдущей статье мы рассмотрели обработку ошибок в сценариях синхронизации, где ошибки обрабатываются блоками try/catch, когда ошибка вызывается с использованием ключевого слова throw. Асинхронный синтаксис и шаблоны сосредоточены на обратных вызовах, абстракциях Promise и синтаксисе async/await.

Существует три способа обработки ошибок в асинхронных сценариях (не исключающих друг друга):

  • Отказ
  • Попробуй поймать
  • Распространение

Отказ

Таким образом, когда ошибка возникает в синхронной функции, это исключение, но когда ошибка возникает в Promise, это асинхронная ошибка или отказ от обещания. По сути, исключения — это синхронные ошибки, а отклонения — асинхронные ошибки.

Вернемся к нашей функции divideByTwo() и преобразуем ее так, чтобы она возвращала обещание:

function divideByTwo(amount) {
  return new Promise((resolve, reject) => {
    if (typeof amount !== 'number') {
      reject(new TypeError('amount must be a number'));
      return;
    }
    if (amount <= 0) {
      reject(new RangeError('amount must be greater than zero'));
      return;
    }
    if (amount % 2) {
      reject(new OddError('amount'));
      return;
    }
    resolve(amount / 2);
  });
}

divideByTwo(3);

Обещание создается с помощью конструктора Promise. Функция, передаваемая Promise, называется функция привязки и принимает два аргумента resolve и reject. При успешном выполнении операции вызывается resolve, а в случае ошибки вызывается reject. Ошибка передается в reject для каждого случая ошибки, поэтому обещание будет отклонено при неверном вводе.

При запуске приведенного выше кода вывод будет:

(node:44616) UnhandledPromiseRejectionWarning: OddError [ERR_MUST_BE_EVEN]: amount must be even

# ... stack trace

Отказ не обрабатывается, потому что Promise должен использовать метод catch для обнаружения отклонений. Подробнее о промисах читайте в статье Понимание промисов в Node.js.

Давайте изменим функцию divideByTwo, чтобы использовать обработчики:

divideByTwo(3)
  .then(result => {
    console.log('result', result);
  })
  .catch(err => {
    if (err.code === 'ERR_AMOUNT_MUST_BE_NUMBER') {
      console.error('wrong type');
    } else if (err.code === 'ERRO_AMOUNT_MUST_EXCEED_ZERO') {
      console.error('out of range');
    } else if (err.code === 'ERR_MUST_BE_EVEN') {
      console.error('cannot be odd');
    } else {
      console.error('Unknown error', err);
    }
  });

Функционал теперь такой же, как и в синхронном непромисном коде) из предыдущей статьи.

Когда внутри обработчика промисов появляется throw, это не будет ошибкой, а будет отклонением. Обработчик then и catch вернет новое обещание, которое будет отклонено в результате throw внутри обработчика.

Асинхронная попытка/поймать

Синтаксис async/await поддерживает try/catch отклонений, что означает, что try/catch можно использовать в асинхронных API на основе обещаний вместо обработчиков then и catch.

Давайте преобразуем код примера для использования шаблона try/catch:

async function run() {
  try {
    const result = await divideByTwo(1);
    console.log('result', result);
  } catch (err) {
    if (err.code === 'ERR_AMOUNT_MUST_BE_NUMBER') {
      console.error('wrong type');
    } else if (err.code === 'ERR_AMOUNT_MUST_EXCEED_ZERO') {
      console.error('out of range');
    } else if (err.code === 'ERR_MUST_BE_EVEN') {
      console.error('cannot be odd');
    } else {
      console.error('Unknown error', err);
    }
  }
}

run();

Единственная разница между синхронной обработкой заключается в заключении в асинхронную функцию и вызове divideByTwo() с await, чтобы асинхронная функция могла автоматически обрабатывать промис.

Использование функции async с try/catch вокруг ожидаемого промиса — это синтаксический сахар. Блок catch в основном такой же, как обработчик catch. Асинхронная функция всегда возвращает обещание, которое разрешается, если не происходит отклонение. Это также означало бы, что мы можем преобразовать функцию divideByTwo из возврата обещания в простое повторение броска. По сути код представляет собой синхронную версию с ключевым словом async.

async function divideByTwo(amount) {
  if (typeof amount !== 'number')
    throw new TypeError('amount must be a number');
  if (amount <= 0)
    throw new RangeError('amount must be greater than zero');
  if (amount % 2) throw new OddError('amount');
  return amount / 2;
}

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

Ошибки во всех этих примерах — ошибки разработчиков. В асинхронном контексте ошибки операций возникают с большей вероятностью. Например, запрос POST по какой-то причине не проходит, и данные не могут быть записаны в базу данных. Схема обработки операционных ошибок такая же. Мы можем await выполнять асинхронную операцию и перехватывать любые ошибки и обрабатывать их соответствующим образом (отправить запрос еще раз, вернуть сообщение об ошибке, сделать что-то еще и т. д.).

Распространение

Другой способ обработки ошибок — распространение. Распространение ошибок — это когда вместо обработки ошибки там, где она возникает, вызывающая сторона отвечает за обработку ошибки. При использовании функций async/await, когда мы хотим распространить ошибку, мы просто выдаем ее повторно.

Давайте рефакторим функцию для распространения неизвестных ошибок:

class OddError extends Error {
  constructor(varName = '') {
    super(varName + ' must be even');
    this.code = 'ERR_MUST_BE_EVEN';
  }
  get name() {
    return 'OddError [' + this.code + ']';
  }
}

function codify(err, code) {
  err.code = code;
  return err;
}

async function divideByTwo(amount) {
  if (typeof amount !== 'number')
    throw codify(
      new TypeError('amount must be a number'),
      'ERR_AMOUNT_MUST_BE_NUMBER',
    );
  if (amount <= 0)
    throw codify(
      new RangeError('amount must be greater than zero'),
      'ERR_AMOUNT_MUST_EXCEED_ZERO',
    );
  if (amount % 2) throw new OddError('amount');
  // uncomment next line to see error propagation
  // throw Error('propagate - some other error');;
  return amount / 2;
}

async function run() {
  try {
    const result = await divideByTwo(4);
    console.log('result', result);
  } catch (err) {
    if (err.code === 'ERR_AMOUNT_MUST_BE_NUMBER') {
      throw Error('wrong type');
    } else if (err.code === 'ERRO_AMOUNT_MUST_EXCEED_ZERO') {
      throw Error('out of range');
    } else if (err.code === 'ERR_MUST_BE_EVEN') {
      throw Error('cannot be odd');
    } else {
      throw err;
    }
  }
}
run().catch(err => {
  console.error('Error caught', err);
});

Неизвестные ошибки распространяются от функции divideByTwo() к блоку catch, а затем к функции run с обработчиком catch. Попробуйте запустить код после раскомментирования throw Error('some other error'); в функции divideByTwo(), чтобы безоговорочно выдать ошибку. Вывод будет примерно таким: Error caught Error: propagate - some other error.

Если и когда ошибка распространяется, это сильно зависит от контекста. Причиной распространения ошибки может быть отказ стратегий обработки ошибок на определенном уровне. Примером может служить неудачный сетевой запрос, который повторялся несколько раз перед распространением.

В общем, старайтесь распространять ошибки для обработки на максимально возможном уровне. Это будет основной файл в модуле, а в приложении — файл точки входа.

TL;DR

  • Исключения — это синхронные ошибки, а отклонения — асинхронные ошибки.
  • Отклонение обещания должно быть обработано. Обработчик catch обрабатывает отказ от обещания.
  • Существует три способа обработки ошибок в асинхронных сценариях: отклонение, попытка/перехват и распространение.
  • Синтаксис async/await поддерживает try/catch отклонений.
  • try/catch можно использовать в асинхронных API на основе обещаний вместо обработчиков then и catch.
  • Распространение ошибок — это когда вместо обработки ошибки там, где она возникает, вызывающая сторона отвечает за обработку ошибки.
  • Распространение ошибки зависит от контекста. При распространении он должен быть максимально возможным.

Спасибо за прочтение. Если у вас есть какие-либо вопросы, используйте функцию комментариев или отправьте мне сообщение @mariokandut .

Если вы хотите узнать больше о Node, ознакомьтесь с этими Учебными пособиями по Node.

Рекомендации (и большое спасибо):

JSNAD, Ошибки MDN, Выброс MDN, Коды ошибок Node.js, Joyent