Асинхронные ошибки в JavaScript: стратегии надежного управления исключениями

Тихий сбой платежа из-за неперехваченного исключения. Висящий индикатор загрузки после сетевого сбоя. Поломанная бизнес-логика из-за частично выполненной цепочки промисов. Неадекватная обработка асинхронных ошибок — бич современных веб-приложений, порождающий самые коварные баги. Когда 89% вызовов API в типичных SPA-приложениях обрабатывают ошибки неправильно или неполно, проблема требует системного решения.

Рассмотрим типичный антипаттерн:

javascript
async function fetchData() {
  const response = await fetch('/api/data');
  const data = await response.json();
  return process(data);
}

// Где-то в коде:
fetchData().then(updateUI);

Беглый взгляд не обнаруживает явных проблем, но эта реализация содержит три фатальных упущения:

  1. Не обрабатывает HTTP-ошибки (404, 500)
  2. Игнорирует исключения в process(data)
  3. Оставляет необработанным отклоненный промис в точке вызова

Контроль состояния ответа HTTP

Первая ловушка — предположение, что fetch автоматически отклоняет промис при HTTP-ошибках. В реальности:

javascript
const response = await fetch('https://api.example/404');
console.log(response.ok); // false
console.log(response.status); // 404

Промис разрешится, но response.ok будет false для статусов ≥400. Обязательная проверка:

javascript
async function safeFetch(url, options) {
  const response = await fetch(url, options);
  if (!response.ok) {
    const error = new Error(`HTTP Error ${response.status}`);
    error.response = response; // Сохраняем контекст
    throw error;
  }
  return response;
}

Каскадное поглощение ошибок

Самой опасной особенностью асинхронного кода является неявное подавление исключений. Решение — композиция обработчиков с явным пробрасыванием:

javascript
class ApiError extends Error {
  constructor(message, { url, status, context }) {
    super(message);
    this.name = 'ApiError';
    this.details = { url, status, context };
  }
}

async function fetchOrder(orderId) {
  try {
    const response = await safeFetch(`/api/orders/${orderId}`);
    const data = await parseJSON(response);
    return validateOrderSchema(data);
  } catch (error) {
    if (error instanceof SyntaxError) {
      throw new ApiError('Invalid JSON', { 
        url: `/api/orders/${orderId}`,
        status: response.status,
        context: { orderId }
      });
    }
    throw error; // Пробрасываем оригинальную ошибку
  }
}

Создание специализированных классов ошибок с контекстом упрощает последующую обработку и логирование.

Транзакционность асинхронных операций

Распространенная ошибка — частичное выполнение последовательных операций. Пример ненадежной реализации:

javascript
async function transferFunds(source, target, amount) {
  await withdraw(source, amount);
  await deposit(target, amount); // Может провалиться после успешного списания
}

Решение через транзакционный подход:

javascript
async function transferFunds(source, target, amount) {
  let committed = false;
  try {
    const withdrawResult = await withdraw(source, amount);
    const depositResult = await deposit(target, amount);
    committed = true;
    return { withdrawResult, depositResult };
  } finally {
    if (!committed) {
      await rollbackWithdraw(source, amount); 
    }
  }
}

Интеграция с системами мониторинга

Перехват глобальных необработанных обещаний критичен для продакшн-систем:

javascript
// В Node.js
process.on('unhandledRejection', (reason, promise) => {
  sentry.captureException(reason, { extra: { promise } });
});

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

Но глобальные обработчики — последняя линия обороны. Предпочтение следует отдавать локальным перехватчикам с контекстным логированием.

Прерываемые операции

С появлением AbortController управление жизненным циклом асинхронных операций стало обязательным навыком:

javascript
const controller = new AbortController();

async function search(query) {
  try {
    const response = await fetch(`/search?q=${query}`, {
      signal: controller.signal
    });
    // Обработка результатов
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('Search aborted');
      return;
    }
    throw error;
  }
}

// Прервать при новом запросе:
function handleSearchInput(query) {
  controller.abort();
  search(query);
}

Реактивное управление состоянием ошибок

В современных SPA-фреймворках централизованная обработка ошибок требует интеграции с состоянием приложения. Пример для React:

javascript
const ErrorContext = createContext();

function ErrorBoundary({ children }) {
  const [error, setError] = useState(null);

  const handleReset = () => setError(null);

  return (
    <ErrorContext.Provider value={{ error, setError }}>
      {error ? (
        <RecoveryUI error={error} onRetry={handleReset} />
      ) : (
        <ErrorBoundaryImpl onError={setError}>{children}</ErrorBoundaryImpl>
      )}
    </ErrorContext.Provider>
  );
}

// Использование в хуках:
function useAsync(fn) {
  const { setError } = useContext(ErrorContext);
  
  useEffect(() => {
    fn().catch(error => {
      setError(error);
    });
  }, [fn, setError]);
}

Заключение

Три ключевых правила надежной обработки асинхронных ошибок:

  1. Всегда предполагайте неопределенность внешних систем
  2. Сохраняйте максимальный контекст для диагностики
  3. Проектируйте композитные операции как атомарные транзакции

Инструменты выросли в возможностях — от прозрачных AbortController до продвинутых TypeScript-типов для discriminated union результатов операций. Но фундаментальные принципы остаются: явность обработки, полнота контекста и стратегическое планирование сценариев отказа. Следующий шаг — интеграция автоматических валидаторов типа ESLint-plugin-promise, но важно помнить — никакие тулы не заменят ревью кода, где проверяют сценарии "а что если тут всё пойдёт не так?".

text