Menu
Русский

Обработка ошибок в async/await: try/catch и Promises

Разбираемся, как ошибки на самом деле ходят по асинхронному коду: try/catch с await, .catch у промисов и типичные ловушки, из-за которых падения остаются незамеченными.

Ошибки в асинхронном коде работают не так, как в синхронном

В синхронном JavaScript выброшенная ошибка поднимается вверх по стеку вызовов, пока её не поймает какой-нибудь try/catch — или программа просто упадёт. В асинхронном коде эта модель ломается. К моменту, когда сетевой запрос завершается ошибкой, функция, которая его запустила, уже давно вернула управление. Подниматься некуда — стека вызовов больше нет.

Промисы решают эту проблему, выделяя ошибкам отдельный канал. Промис может либо завершиться успехом со значением, либо отклониться с причиной. Отклонение (rejection) — это асинхронный аналог throw. Вся эта страница, по сути, о том, как сделать так, чтобы отклонения попадали туда, где вы их контролируете, а не растворялись в пустоте.

index.js
Output
Click Run to see the output here.

try/catch отрабатывает и спокойно завершается. А реджект случается через 50 мс — когда блок try уже давно закрыт. Ловить ошибку некому. Вот она, ловушка.

try/catch снова работает — но только с await

Как только вы ставите await перед промисом, реджект превращается в обычный throw внутри async-функции. И внешний try/catch ловит его точно так же, как синхронное исключение:

index.js
Output
Click Run to see the output here.

Это первый паттерн, к которому стоит тянуться. await возвращает асинхронный мир к привычной форме try/catch: помещаем вызовы с await, которые могут упасть, внутрь try, а обрабатываем их в catch.

Но есть нюанс: ловится только то, что реально ожидается через await. Если вы запустили промис без await, ошибка из него ускользнёт.

Самая частая ошибка: забыли написать await

Если вызвать асинхронную функцию без await (и при этом не вернуть её промис), реджекты просто проскочат мимо вашего try/catch:

index.js
Output
Click Run to see the output here.

Блок try отрабатывает успешно, а реджект происходит уже на следующем тике — ловить его попросту некому. В консоли тут же вылезет предупреждение «unhandled promise rejection».

Лечится это всегда одинаково: либо поставить await перед вызовом, либо сделать return промиса — тогда его сможет заавейтить вызывающий код.

index.js
Output
Click Run to see the output here.

.catch() — обратная сторона той же медали

Если не хочется тащить async/await, отлавливать ошибки в промисах можно через цепочку .catch():

index.js
Output
Click Run to see the output here.

.catch(fn) — это короткая запись для .then(undefined, fn). Такой обработчик ловит любое отклонение, случившееся выше по цепочке. .catch() в самом конце цепочки — асинхронный аналог внешнего try/catch, последняя линия обороны перед тем, как reject станет «необработанным».

Смешивать оба стиля — абсолютно нормально. Частый приём: внутри функции используем async/await, а вызывающий код вешает .catch() снаружи:

index.js
Output
Click Run to see the output here.

fetch не реагирует на HTTP-ошибки

На эти грабли наступает каждый хотя бы раз. fetch отклоняет промис только при сетевых сбоях — когда не разрешился DNS, соединение отвергнуто или запрос был прерван. А вот ответы 404 или 500 он считает успешным запросом. Промис спокойно зарезолвится — просто в ответе поле ok будет false.

index.js
Output
Click Run to see the output here.

Если нужно, чтобы HTTP-ошибки попадали в блок catch, проверяйте res.ok и бросайте исключение вручную:

index.js
Output
Click Run to see the output here.

Это шаблонный код, который стоит вынести в хелпер, как только вы напишете его второй раз.

Promise.all падает сразу, Promise.allSettled — нет

Promise.all принимает массив промисов и резолвится массивом результатов — если только один из них не зареджектится. В этом случае метод сразу же завершится с ошибкой. Остальные промисы при этом продолжат выполняться, но их результаты просто выбросятся в никуда.

index.js
Output
Click Run to see the output here.

Стратегия fail-fast оправдана, когда вам нужны все результаты без исключения и любой сбой обнуляет смысл всей операции. Если же вам важен исход каждой задачи — «попробуй залить эти пять файлов и скажи, какие прошли, а какие упали», — берите Promise.allSettled:

index.js
Output
Click Run to see the output here.

allSettled никогда не отклоняется. Каждый элемент — это либо {status: "fulfilled", value}, либо {status: "rejected", reason}.

Проброс ошибки и точечные catch-блоки

Не всякую ошибку стоит ловить в одном и том же обработчике. Типичный приём — поймать ошибку, разобраться, что это, и пробросить дальше всё, чего вы не ожидали:

index.js
Output
Click Run to see the output here.

Глушить любую ошибку пустым catch (err) {} — верный способ спрятать настоящие баги. Ловите только то, с чем реально умеете работать, остальное — пробрасывайте дальше.

unhandledrejection в JavaScript — ваша страховочная сетка

Как бы аккуратно ни был написан код, рано или поздно что-то проскочит мимо. И в Node.js, и в браузере для этого есть глобальный хук, который срабатывает на необработанные отклонения промисов:

// Browser
window.addEventListener("unhandledrejection", event => {
    console.error("unhandled:", event.reason);
    event.preventDefault(); // подавить стандартное предупреждение в консоли
});

// Node.js
process.on("unhandledRejection", reason => {
    console.error("unhandled:", reason);
});

Это не замена нормальной обработке ошибок — скорее последний рубеж для логов или телеметрии. В современном Node.js необработанный rejection по умолчанию роняет процесс, и в продакшене это как раз то, что нужно: залогировать ошибку, дать процессу упасть и перезапуститься с чистого листа.

Практический чек-лист

Каждый раз, когда async-функция дёргает что-то потенциально падающее, задайте себе вопросы:

  • Каждый ли рискованный await обёрнут в try/catch, или возвращённый промис обрабатывается у вызывающей стороны через .catch()?
  • Я действительно пишу await перед вызовом — или случайно запустил промис и забыл про него?
  • Если это fetch, проверяю ли я res.ok перед тем, как доверять ответу?
  • Когда запускаю задачи параллельно — подходит ли здесь Promise.all, или лучше взять Promise.allSettled?
  • Есть ли где-то наверху .catch() или обработчик unhandledrejection, чтобы ошибка не растворилась бесследно?

Разберитесь с этими пятью пунктами — и асинхронный код перестанет подкидывать вам сюрпризы в виде ошибок, пропадающих где-то в недрах event loop.

Дальше: ES-модули

На этом тема обработки ошибок в асинхронном коде закрыта. Дальше поговорим о том, как JavaScript-код раскладывается по файлам: import, export и система модулей, на которой держится любой современный проект.

Часто задаваемые вопросы

Как ловить ошибки внутри async-функции?

Оборачиваем вызовы с await в блок try/catch. Любой reject у ожидаемого промиса превращается в обычное исключение, которое попадает в catch. Второй вариант — пробросить ошибку выше и обработать её на стороне вызова через .catch() на возвращённом промисе.

Почему try/catch не ловит мою ошибку?

Чаще всего потому, что ошибка случается в коде, который вы не await-или. Если вызвать async-функцию без await (и не вернуть её промис наружу), reject просто пролетит мимо try/catch. Правило простое: либо await, либо return того промиса, из которого хотите получить ошибку.

Что будет, если промис упал, а его никто не поймал?

Получите unhandled rejection. В Node.js срабатывает событие unhandledRejection, и в свежих версиях процесс по умолчанию падает. В браузере стреляет window.onunhandledrejection и в консоль летит предупреждение. Решение одно — повесить .catch() или обернуть await в try/catch.

Как ведёт себя Promise.all при ошибках?

Promise.all реджектится сразу, как только падает любой из входных промисов. Остальные продолжают выполняться, но их результаты уже никому не нужны. Если важен итог каждого промиса — независимо от того, упал он или нет — используйте Promise.allSettled: на выходе получите массив объектов {status, value} или {status, reason}.

Учитесь программировать с Coddy

НАЧАТЬ