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

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

Технический фундамент распространения ошибок

В асинхронном коде ошибка проходит по цепочке вверх до ближайшего обработчика. Важно понимать:

  • reject в Promise-цепочке эквивалентен "брошенной" ошибке для следующего шага;
  • throw внутри async возвращает rejected Promise;
  • потеря контекста в catch делает диагностику дорогой;
  • иногда ошибку нужно обработать частично и пробросить дальше (rethrow).

Фундаментальный принцип: у каждой ошибки должен быть наблюдаемый маршрут и явное решение на каждом уровне.

Почему асинхронные ошибки сложнее обычных

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

Ключевой момент: каждая асинхронная операция должна иметь явный путь обработки ошибки.

Смотри, что важно: try...catch ловит только ошибки, которые происходят внутри текущего синхронного выполнения. Если throw случится позже (например, в setTimeout), внешний try...catch его не поймает.

try {
  setTimeout(() => {
    throw new Error('boom');
  }, 0);
} catch (error) {
  console.log('не попадем сюда');
}

Анти-провал: ловить/превращать ошибку нужно внутри асинхронного обработчика (или переводить сценарий в Promise и работать через catch/await).

Проверь себя: что опаснее - явный rejected, который поймали, или rejected, о котором никто не узнал?

Ошибки в Promise-цепочке

Если Promise отклоняется (reject) и нет catch, ошибка остается необработанной.

function loadData() {
  return Promise.reject(new Error('Сервер недоступен'));
}

loadData()
  .then((data) => console.log(data))
  .catch((error) => {
    console.error('Ошибка загрузки:', error.message);
  });

Здесь часто путаются: ошибка может появиться не только в самом запросе, но и внутри любого then.

Promise.resolve({ price: null })
  .then((item) => item.price.toFixed(2))
  .catch((error) => console.log('Поймали:', error.message));

Ошибки в async/await

С async/await основной паттерн обработки - try...catch.

async function getProducts() {
  try {
    const response = await fetch('/api/products');
    if (!response.ok) throw new Error('HTTP error');

    return await response.json();
  } catch (error) {
    console.error('getProducts failed:', error.message);
    return [];
  }
}

Смотри, что важно: сетевой fetch не бросает ошибку на HTTP 404/500 автоматически, поэтому проверка response.ok обязательна.

Смотри, что важно: try...catch в async-функции ловит только то, что ты await-ишь. Если забыть await, ошибка может стать необработанной.

async function save() {
  try {
    apiSave(); // забыли await
    console.log('считаем, что сохранили...');
  } catch (error) {
    // сюда не попадем при rejected из apiSave()
  }
}

Анти-провал: если хочешь обработать ошибку здесь, пиши await apiSave(). Если хочешь пробросить ошибку наружу, делай return apiSave() и лови ее у вызывающего.

Проверь себя: почему catch может не сработать на 404 без if (!response.ok) throw ...?

Глобальные ловушки и почему на них нельзя полагаться

В Node.js и браузере есть глобальные обработчики необработанных ошибок, но это "последняя линия защиты", а не основной способ.

Примеры:

  • браузер: window.addEventListener('unhandledrejection', ...), window.addEventListener('error', ...);
  • Node.js: process.on('unhandledRejection', ...), process.on('uncaughtException', ...).

Почему:

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

Анти-провал: обрабатывай ошибки как можно ближе к месту, где операция запускается.

Мини-сценарии из продукта

  1. Повторная попытка (retry) при временном сетевом сбое.
  • первая попытка упала;
  • показываем пользователю статус "повторяем";
  • пробуем еще раз ограниченное число раз;
  • если не помогло, выдаем понятную ошибку.
  1. Частичный fallback.
  • основной API упал;
  • показываем кешированные данные;
  • логируем инцидент для команды.

Это лучше, чем пустой экран или бесконечный spinner.

Частые ошибки новичков

  • Забывать catch в Promise-цепочке.
  • Ловить ошибку, но ничего не делать (нет лога, нет fallback).
  • Смешивать бизнес-ошибки и технические в одно сообщение.
  • Использовать слишком общий catch без контекста.
async function save() {
  try {
    await apiSave();
  } catch {
    // Плохо: потеряли причину
  }
}

Лучше всегда иметь доступ хотя бы к error.message и контексту операции.

Что будет, если изменить входные данные

Если item.price в примере с toFixed будет числом, цепочка пройдет успешно. Если null или undefined, возникнет ошибка типа, и она уйдет в catch. Это предсказуемое поведение, если обработка выстроена правильно.

Проверь себя: как ты отделишь ошибку валидации пользовательского ввода от ошибки сети в одном обработчике?

Практичный чеклист для асинхронной ошибки

  1. Есть ли catch или try...catch у каждой операции?
  2. Логируется ли контекст (operation, вход, message)?
  3. Есть ли понятный fallback для пользователя?
  4. Проверяешь ли ты response.ok для HTTP-ответов?
  5. Не теряешь ли оригинальную ошибку при пробросе дальше?

Дополнительный пример: добавление контекста и повторный проброс.

async function loadWithContext() {
  try {
    return await loadData();
  } catch (error) {
    console.error('loadWithContext failed:', error.message);
    throw error;
  }
}

Краткий итог

  • Асинхронные ошибки нужно обрабатывать явно в каждой точке запуска операции.
  • В Promise-цепочках за это отвечает catch, в async/await - try...catch.
  • Для HTTP важно проверять response.ok, иначе часть ошибок останется незамеченной.
  • Лог + контекст + fallback делают поведение системы устойчивым.
  • Необработанные Promise-ошибки это сигнал архитектурной дыры, а не мелочь.