Обработка асинхронных ошибок в JavaScript: От хаоса к контролю

В экосистеме JavaScript, где операции ввода-вывода доминируют, асинхронный код стал краеугольным камнем. Однако разработчики часто сталкиваются с проблемой, когда неожиданная ошибка в цепочке промисов или async/await приводит к критическому сбою приложения. Рассмотрим, почему код вида fetchData().then(render).catch(logError) может быть недостаточным, и как проектировать устойчивые системы.

Типовые провалы

1. Молчаливое проглатывание ошибок

javascript
async function updateUserProfile(userId) {
  const data = await fetch(`/api/users/${userId}`);
  // Ошибка: нет try/catch. При падении fetch исключение распространится за пределы функции
  const json = await data.json();
  return json;
}

Проблема: Неперехваченные rejection'ы промисов вызывают событие unhandledRejection в Node.js. В браузере это приводит к расплывчатым сообщениям в консоли, маскирующим коренную причину.

2. Иллюзия обработки в Promise.all

javascript
Promise.all([queryDatabase(), fetchExternalService()])
  .then(([dbResult, apiResult]) => {
    // Один сбой отклоняет весь Promise.all
  })
  .catch(e => console.error('Что-то сломалось'));

Последствия: При параллельном выполнении задач потеря контекста сбоя делает отладку игрой в угадайку: «Это СУБД не ответила или API вернул 500?».

3. Смертельные микротаски

javascript
function initApp() {
  initializeAnalytics() // Возвращает промис
    .then(() => console.log('Analytics up'));
  // Пропущенный catch: необработанный rejection убьёт процесс Node.js
}

Эффект: В Node.js процесс завершится с кодом 1 при необработанной ошибке, даже если приложение могло продолжать работу.

Инженерные стратегии

Контекстное логгирование с метаданными

javascript
class DatabaseError extends Error {
  constructor(query, params, message) {
    super(message);
    this.query = query; // Контекст операции
    this.params = params; // Параметры для воспроизведения
  }
}

async function getUser(id) {
  try {
    return await db.query('SELECT * FROM users WHERE id = ?', [id]);
  } catch (e) {
    throw new DatabaseError(
      'SELECT * FROM users WHERE id = ?',
      [id],
      `User lookup failed: ${e.message}`
    );
  }
}

Зачем: Без контекста ошибка «Connection timeout» бесполезна. Добавление параметров запроса, стеков вызовов и типа операции позволяет воспроизвести сценарий.

Декларативная обработка в Promise.allSettled

javascript
const [userResult, auditResult] = await Promise.allSettled([
  getUser(userId),
  logAuditEvent('PROFILE_VIEW', userId)
]);

const errors = [userResult, auditResult]
  .filter(r => r.status === 'rejected')
  .map(r => r.reason);

if (errors.length > 0) {
  await sendErrorReport(errors); // Отправка всех ошибок разом
  if (userResult.status === 'rejected') throw userResult.reason;
}

Преимущество: Частичный сбой не блокирует выполнение. Решение о фатальности ошибки принимается явно, а не делегируется механизму промисов.

Паттерн Circuit Breaker для внешних вызовов

javascript
const circuit = new CircuitBreaker(async (url) => {
  const res = await fetch(url);
  if (res.status >= 500) throw new Error(`HTTP ${res.status}`);
  return res.json();
}, {
  timeout: 1000, // Автоматический отказ при задержке
  threshold: 5, // Максимум ошибок до разрыва цепи
  resetPeriod: 30000 // Время до попытки восстановления
});

async function safeFetch(url) {
  try {
    return await circuit.fire(url);
  } catch (e) {
    if (e === CircuitBreaker.OPEN) { // Обход заблокированных вызовов
      return cachedVersion();
    }
    throw e;
  }
}

Зачем: Защита от каскадных сбоев при отказе внешних сервисов. Автоматическое восстановление после таймаута предотвращает ручной перезапуск.

Интеграционные точки

Глобальные обработчики как последняя линия обороны

javascript
// Node.js
process.on('unhandledRejection', (reason, promise) => {
  metrics.increment('unhandled_rejection'); // Мониторинг
  logger.fatal({ reason, promise }, 'UNHANDLED REJECTION');
  // Не завершать процесс явно: modern Node.js (v15+) делает это с кодом 1
});

// Браузер
window.addEventListener('unhandledrejection', event => {
  event.preventDefault(); // Подавление стандартного вывода
  trackError(event.reason);
  if (isCritical(event.reason)) {
    showErrorPage(event.reason);
  }
});

Предостережение: Глобальные обработчики — это экстренный клапан, а не замена локальному try/catch. Их задача — логировать неизвестные сбои и завершать работу предсказуемо.

Заключение

Обработка асинхронных ошибок — это проектирование системы, а не добавление последусловий. Ключевые практики:

  • Контекстные ошибки с метаданными вместо строковых сообщений
  • Явное управление частичными сбоями через Promise.allSettled
  • Глобальные обработчики как механизм observability, а не восстановления
  • Шаблоны устойчивости (Circuit Breaker, Retry с экспоненциальной задержкой)

Интеграция этих методов снижает количество инцидентов, где пользователь видит «Something went wrong», а разработчик — туманную ошибку без стека. Тестируйте сценарии отказа так же тщательно, как happy path: запускайте Chaos Monkey в staging-окружении, имитируйте обрыв сети и таймауты. Устойчивость — это не отсутствие ошибок, а предсказуемое поведение при их возникновении.

text