Навигация по морю асинхронных ошибок в JavaScript: Стратегии за пределами `try/catch`

Даже опытные разработчики сталкиваются с коварной проблемой: тихая смерть приложения из-за неперехваченной асинхронной ошибки. Картина знакома: код работает при идеальных условиях, но падает без логов или вменяемых диагностических сообщений при реальных сбоях сети, невалидных ответах API или неожиданных исключениях. Почему стандартные методы терпят неудачу и как построить устойчивую систему?

Пределы try/catch в асинхронном мире
Классическая конструкция try/catch беспомощна против ошибок в асинхронных операциях вне своего лексического контекста. Пример-убийца:

javascript
async function fetchData() {
  try {
    const response = await fetch('https://api.example.com/data');
    const data = await response.json(); // Ошибка: response не получен
    return data;
  } catch (err) {
    console.error('Локальный перехват:', err);
  }
}

// Вызов функции НЕ защищен try/catch
const result = fetchData();
result.then(processData); // Непойманный rejected promise!

Здесь ошибка в fetchData() приведёт к отклонению промиса, который обрабатывается в then. Отсутствие обработчика на этом уровне вызовет UnhandledPromiseRejection.

Слои обороны: архитектура устойчивости

  1. Глобальные ловцы асинхронных крахов
    Регистрируйте обработчики на уровне процесса/браузера:
javascript
// Node.js
process.on('unhandledRejection', (reason, promise) => {
  logger.critical('Неперехваченный промис:', reason);
  // Мягкий restart сервиса и отчёт в Sentry
});

// Браузер
window.addEventListener('unhandledrejection', (event) => {
  event.preventDefault();
  telemetry.trackError(event.reason);
});

Это последний рубеж, но он не заменяет локальную обработку. Отсутствие деталей контекста усложняет диагностику.

  1. Декларативная обработка Promise: токсичные антипаттерны
    Типичная ошибка:
javascript
getUserData()
  .then(updateUI)
  .catch(err => console.log(err)); // Поглощение ошибки без действий!

Решение? Всегда возвращайте результат обработки. Используйте цепочки с интеллектом:

javascript
fetchConfig()
  .then(validateConfig)
  .then(initApp)
  .catch(err => {
    if (err instanceof NetworkError) showOfflineBanner();
    else if (err.name === 'ValidationError') restoreBackup();
    else throw err; // Проброс нефатальных ошибок дальше
  });
  1. Обработаете все при "параллельных" операциях
    Promise.all рухнет при первой ошибке. Для независимых задач:
javascript
// Сохраняет все результаты (including ошибки)
const results = await Promise.allSettled([
  fetchOrders(),
  fetchUsers(),
  fetchInventory()
]);

const errors = results
  .filter(r => r.status === 'rejected')
  .map(r => r.reason);

if (errors.length > 0) {
  reportToMonitoring(errors);
}

Для кворума успешных ответов (Promise.any) или первого успеха после выполненных (Promise.race с таймаутами) используйте аналогичные тактики изолирования ошибок.

  1. Структурные паттерны для сложных приложений
    А. Обёртки для контролируемых сайд-эффектов
typescript
async function safeAsync<T>(
  fn: () => Promise<T>,
  ctx?: string
): Promise<[T, null] | [null, Error]> {
  try {
    const data = await fn();
    return [data, null];
  } catch (err) {
    err.context = ctx; // Обогащение ошибки
    return [null, err];
  }
}

// Использование
const [user, error] = await safeAsync(() => fetchUser(uid), 'UserProfile');

if (error) {
  if (error.context === 'UserProfile') handleProfileError();
}

Б. Функциональные pipelines с обработкой
Композиция асинхронных шагов с монолитической обработкой ошибок:

javascript
const processOrder = pipeAsync(
  fetchOrder,
  validatePayment,   // Нарушение валидации остановит весь pipe
  updateInventory,
  sendConfirmation
).catch(err => rollbackTransaction(err));
  1. Интеграция с фреймворками
    React: Используйте Error Boundaries исключительно для событий рендеринга. Для обработки асинхронных ошибок в эффектах:
javascript
useEffect(() => {
  fetchData()
    .then(setData)
    .catch(err => setError(err)); // Контроллируемый стейт ошибки
}, []);

Node.js: Middleware с централизованной ошибкой в Express/Koa:

javascript
// Koa
app.use(async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    ctx.status = err.isCustom ? 400 : 500;
    ctx.body = { error: err.message };
    // Пуш в централизованный логгер
  }
});

Диагностика ошибок как часть дизайна

  • Добавляйте уникальные коды ошибок:
    throw new Error('USER_FETCH_FAILED: DB timeout')
  • Прикрепляйте контекст: идентификаторы запросов, параметры вызова, состояние приложения
  • Разделение фатальных и нефотальных сбоев: ReservedDisconnect можно игнорировать, а DatabaseFailure транслируем.

Инструменты: OpenTelemetry для трассировки, структурированные логи через Pino/Bunyan, Sentry для фронтенд-ошибок.

Не позволяйте асинхронным ошибкам подрывать систему в темноте. Архитектура устойчивости — это не добавление случайных catch на глаз, а дерзкий дизайн потоков данных и ошибок. Расширяйте оборону от unhandledrejection до каждого кастомного промиса, срабатывающего в вашем коде. Мёртвые промисы должны оставлять кристально чистый след из логов — тогда даже ошибки становятся упорядоченными событиями жизненного цикла приложения, а не причинами хаоса.