ПроКодинг - Откроем для вас мир IT!

Представьте ситуацию: вы пишете функцию для загрузки данных с сервера. Код выглядит чисто, логика понятна, но вдруг API возвращает ошибку 500. Что происходит дальше? Если вы используете классические Promises без должной защиты, приложение может упасть молча, а пользователь увидит лишь белое окно или бесконечный спиннер. Именно здесь на помощь приходит связка async/await, которая превращает асинхронный код в синхронный по внешнему виду, но требует особого подхода к обработке исключений.

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

Ключевые моменты

  • async/await упрощает чтение кода, но скрывает механику работы промисов.
  • Ошибки внутри async-функций автоматически превращаются в отклоненные промисы (rejected promises).
  • Необходимо явно обрабатывать ошибки на каждом уровне вызова, если данные критичны для логики.
  • Глобальный обработчик window.onerror или unhandledrejection служит последней линией обороны.
  • Использование библиотек вроде Axios меняет поведение при сетевых ошибках, требуя проверки статуса ответа.

Как работают async и await под капотом

Когда вы объявляете функцию с ключевым словом async, она всегда возвращает промис. Даже если внутри функции нет ни одного await и просто возвращается обычное значение, это значение будет обёрнуто в Promise.resolve(). Это фундаментальное свойство определяет, как мы должны взаимодействовать с такими функциями.

Оператор await останавливает выполнение текущей функции до тех пор, пока промис не разрешится. Если промис отклоняется (rejects), управление передается ближайшему блоку catch. Если блока catch нет, ошибка «улетает» наружу, делая саму async-функцию отклоненным промисом.

Сравнение стилей обработки ошибок
МетодЧитаемостьУстойчивость к ошибкамСложность отладки
Chain .then().catch()Низкая (пирамида ужаса)ВысокаяСредняя
async/await + try/catchВысокаяЗависит от реализацииНизкая
Функции-обертки (Result pattern)СредняяМаксимальнаяВысокая

Базовый паттерн: try...catch внутри async-функции

Самый интуитивный способ - обернуть потенциально опасный код в блок try...catch. Давайте посмотрим на конкретный пример загрузки данных через fetch API.

async function loadUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);
    
    // Важно: fetch не бросает ошибку при HTTP 4xx/5xx!
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    
    const data = await response.json();
    return data;
  } catch (error) {
    console.error('Ошибка загрузки пользователя:', error);
    throw error; // Перебрасываем дальше, если нужно
  }
}

Здесь есть важный нюанс, который часто упускают новички. Метод fetch отклоняет промис только при сетевой ошибке (например, нет интернета). Если сервер вернет статус 404 или 500, промис разрешится успешно, но с объектом ответа, где ok равно false. Поэтому проверка response.ok обязательна.

Проблема «потерянных» ошибок

Что произойдет, если вы вызовете async-функцию и забудете обработать ее результат? Ошибка станет «необработанной» (unhandled rejection). В старых версиях браузеров это могло привести к падению всего скрипта, но современные движки JS обычно просто пишут предупреждение в консоль.

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

  1. Локальная обработка: Всегда используйте try...catch там, где функция вызывается.
  2. Глобальная страховка: Настройте слушатели событий unhandledrejection (браузер) или process.on('unhandledRejection') (Node.js).
Рабочее место разработчика ночью с ноутбуком, показывающим ошибку загрузки интерфейса

Обработка ошибок в циклах и массивах

Частая боль разработчиков - как загрузить несколько ресурсов параллельно и понять, что пошло не так, если один из них сломался. Стандартный подход - использование Promise.all.

async function loadAllUsers(ids) {
  try {
    const results = await Promise.all(
      ids.map(id => loadUser(id))
    );
    return results;
  } catch (error) {
    // Здесь упадет ВСЯ партия, если хоть одна загрузка даст сбой
    console.error('Ошибка массовой загрузки:', error);
    throw error;
  }
}

Но что если нам важно сохранить успешные загрузки, даже если одна из них провалилась? Тогда Promise.all не подходит, так как он отменяет все остальные промисы при первом отказе. В таком случае лучше использовать Promise.allSettled, которое дожидается завершения всех промисов независимо от их статуса.

Паттерн Result: когда try/catch мешает

В некоторых архитектурных стилях (особенно в функциональном программировании) считается, что исключения - это плохо, так как они ломают типизацию и поток выполнения. Альтернатива - возвращать объект со статусом успеха или ошибки.

async function safeLoadUser(id) {
  try {
    const user = await loadUser(id);
    return { success: true, data: user };
  } catch (error) {
    return { success: false, error };
  }
}

// Использование:
const result = await safeLoadUser(1);
if (result.success) {
  console.log(result.data);
} else {
  console.error(result.error);
}

Этот подход делает ошибки частью нормального потока данных. Он особенно полезен в библиотеках, где вы не хотите навязывать пользователю стиль обработки исключений. Однако он увеличивает объем кода, поэтому стоит применять его избирательно.

Инструменты и библиотеки

Если вы используете Axios вместо нативного fetch, логика немного меняется. Axios автоматически бросает ошибку при не-2xx статусах, но сохраняет ответ в поле error.response. Это позволяет удобно отделять сетевые сбои от бизнес-логики.

Для сложных приложений с множеством состояний часто используют стейт-менеджеры, такие как Redux Toolkit или Zustand. Они позволяют централизованно управлять статусами загрузки и ошибок, убирая дублирование кода try/catch из каждого компонента.

Абстрактная 3D-визуализация параллельных процессов, где один элемент поврежден, но остальные стабильны

Типизация ошибок в TypeScript

Если вы пишете на TypeScript, обработка ошибок становится еще более строгой. Вы можете создать собственный класс ошибки, расширяющий базовый Error, и добавить в него дополнительные поля, например, код ошибки или payload.

class ApiError extends Error {
  constructor(message: string, public status: number) {
    super(message);
    this.name = 'ApiError';
  }
}

Такой подход позволяет в блоке catch проверять тип ошибки с помощью instanceof и реагировать на разные виды сбоев по-разному: показывать тост при 401, перенаправлять на страницу ошибки при 404 и т.д.

Частые ошибки и как их избежать

  • Забытый return в catch: Если вы ловите ошибку и не перебрасываете ее, а также не возвращаете значение, функция может вернуть undefined, что приведет к крашу при обращении к свойствам результата.
  • Игнорирование таймаутов: Длинноживущие промисы могут зависнуть навсегда. Используйте AbortController для fetch или параметр timeout в axios.
  • Двойная обработка: Если вы обрабатываете ошибку и в компоненте, и в глобальном перехватчике, пользователь может увидеть два уведомления о сбое.

Практические советы для продакшена

В реальных проектах важна не только корректность, но и наблюдаемость. Логируйте ошибки с контекстом: какой метод был вызван, какие параметры были переданы, каков стектрейс. Инструменты вроде Sentry или LogRocket помогут собирать эти данные централизованно.

Также помните о производительности. Обработка ошибок сама по себе дёшева, но создание объектов ошибок (new Error()) занимает время. Не создавайте лишние объекты в горячих путях кода, если ошибка маловероятна.

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

Нужен ли try/catch, если я использую .catch()?

Да, если вы используете async/await. Оператор await работает только с try/catch. Метод .catch() работает с цепочками промисов (.then()). Смешивать эти стили в одной функции не рекомендуется, так как это усложняет чтение кода.

Что делать, если fetch вернул 200, но JSON битый?

Метод response.json() может бросить SyntaxError, если тело ответа не является валидным JSON. Этот блок тоже нужно оборачивать в try/catch или проверять заголовок Content-Type перед парсингом.

Как отменить запрос, если компонент размонтировался?

Используйте AbortController. Создайте контроллер, передайте его signal в fetch, а в useEffect (или аналоге) добавьте cleanup-функцию, которая вызывает controller.abort(). Это предотвратит обновление состояния удаленного компонента.

Какой метод лучше: Promise.all или Promise.allSettled?

Promise.all подходит, когда успех зависит от всех операций (например, загрузка всех частей страницы). Promise.allSettled нужен, когда вам важны результаты каждой операции отдельно, и сбой одной не должен мешать другим (например, загрузка рекомендаций, которые можно показать частично).

Стоит ли создавать свои классы ошибок?

Да, если у вас есть специфические типы сбоев (сетевые, валидации, авторизации). Это упрощает обработку на клиенте и улучшает типизацию в TypeScript. Для простых скриптов достаточно стандартного Error.