Ошибки в асинхронном коде работают не так, как в синхронном
В синхронном JavaScript выброшенная ошибка поднимается вверх по стеку вызовов, пока её не поймает какой-нибудь try/catch — или программа просто упадёт. В асинхронном коде эта модель ломается. К моменту, когда сетевой запрос завершается ошибкой, функция, которая его запустила, уже давно вернула управление. Подниматься некуда — стека вызовов больше нет.
Промисы решают эту проблему, выделяя ошибкам отдельный канал. Промис может либо завершиться успехом со значением, либо отклониться с причиной. Отклонение (rejection) — это асинхронный аналог throw. Вся эта страница, по сути, о том, как сделать так, чтобы отклонения попадали туда, где вы их контролируете, а не растворялись в пустоте.
try/catch отрабатывает и спокойно завершается. А реджект случается через 50 мс — когда блок try уже давно закрыт. Ловить ошибку некому. Вот она, ловушка.
try/catch снова работает — но только с await
Как только вы ставите await перед промисом, реджект превращается в обычный throw внутри async-функции. И внешний try/catch ловит его точно так же, как синхронное исключение:
Это первый паттерн, к которому стоит тянуться. await возвращает асинхронный мир к привычной форме try/catch: помещаем вызовы с await, которые могут упасть, внутрь try, а обрабатываем их в catch.
Но есть нюанс: ловится только то, что реально ожидается через await. Если вы запустили промис без await, ошибка из него ускользнёт.
Самая частая ошибка: забыли написать await
Если вызвать асинхронную функцию без await (и при этом не вернуть её промис), реджекты просто проскочат мимо вашего try/catch:
Блок try отрабатывает успешно, а реджект происходит уже на следующем тике — ловить его попросту некому. В консоли тут же вылезет предупреждение «unhandled promise rejection».
Лечится это всегда одинаково: либо поставить await перед вызовом, либо сделать return промиса — тогда его сможет заавейтить вызывающий код.
.catch() — обратная сторона той же медали
Если не хочется тащить async/await, отлавливать ошибки в промисах можно через цепочку .catch():
.catch(fn) — это короткая запись для .then(undefined, fn). Такой обработчик ловит любое отклонение, случившееся выше по цепочке. .catch() в самом конце цепочки — асинхронный аналог внешнего try/catch, последняя линия обороны перед тем, как reject станет «необработанным».
Смешивать оба стиля — абсолютно нормально. Частый приём: внутри функции используем async/await, а вызывающий код вешает .catch() снаружи:
fetch не реагирует на HTTP-ошибки
На эти грабли наступает каждый хотя бы раз. fetch отклоняет промис только при сетевых сбоях — когда не разрешился DNS, соединение отвергнуто или запрос был прерван. А вот ответы 404 или 500 он считает успешным запросом. Промис спокойно зарезолвится — просто в ответе поле ok будет false.
Если нужно, чтобы HTTP-ошибки попадали в блок catch, проверяйте res.ok и бросайте исключение вручную:
Это шаблонный код, который стоит вынести в хелпер, как только вы напишете его второй раз.
Promise.all падает сразу, Promise.allSettled — нет
Promise.all принимает массив промисов и резолвится массивом результатов — если только один из них не зареджектится. В этом случае метод сразу же завершится с ошибкой. Остальные промисы при этом продолжат выполняться, но их результаты просто выбросятся в никуда.
Стратегия fail-fast оправдана, когда вам нужны все результаты без исключения и любой сбой обнуляет смысл всей операции. Если же вам важен исход каждой задачи — «попробуй залить эти пять файлов и скажи, какие прошли, а какие упали», — берите Promise.allSettled:
allSettled никогда не отклоняется. Каждый элемент — это либо {status: "fulfilled", value}, либо {status: "rejected", reason}.
Проброс ошибки и точечные catch-блоки
Не всякую ошибку стоит ловить в одном и том же обработчике. Типичный приём — поймать ошибку, разобраться, что это, и пробросить дальше всё, чего вы не ожидали:
Глушить любую ошибку пустым 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}.