Обработка асинхронных ошибок в JavaScript: от колбэков до async/await без катастроф

Элегантная обработка ошибок — визитная карточка зрелого разработчика. В асинхронном мире JavaScript отсутствие стратегии превращает ваш код в мину замедленного действия: падения без логов, незавершенные транзакции, непредсказуемое поведение системы. Рассмотрим практический гид по архитектуре устойчивых приложений.

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

Стек вызовов в асинхронных операциях разрывается. Ошибка внутри колбэка setTimeout отвергнутого промиса или забытого await не всплывает через классический try/catch. Результат — молчаливый краш вместо контролируемого инцидента.

javascript
// Катастрофический антипаттерн
async function fetchData() {
  const data = await fetch("/api").then(res => res.json());
  // Что если запрос упадет с 500? Или JSON окажется битым?
  processData(data); 
}

Эволюция обработки: от ада колбэков к промисам

Колбэк-армагеддон:

javascript
fs.readFile("config.json", (err, config) => {
  if (err) return log(err); // Пропустили return? Дальше код выполнится!
  db.connect(config.dbUrl, (err, client) => {
    if (err) return log(err);
    client.query("SELECT * FROM users", (err, result) => {
      if (err) return log(err);
      // callback hell
    });
  });
});

Проблемы: Глубоко вложенные условия, сложная читаемость, риски пропустить return.

Промисы: прогресс с подводными камнями

javascript
fetch("/api/users")
  .then(response => response.json())
  .then(data => renderUsers(data))
  .catch(error => console.error("Fetch failed", error)); // Ловим ошибки сети, но...

Чейн промисов прерывается при первом .catch(). Упущенное: ловим все ошибки цепочки одним обработчиком, но рискуем:

  • Неотлавливаемые исключения вне промисов
  • Ошибки в колбэках внутри .then() (если не возвращаем промис)
  • "Проглоченные" ошибки без реакции приложения

Критический нюанс: возвраты в then()

javascript
fetch("/api")
  .then(response => {
    response.json().then(data => { // Вложенный промис без возврата -> риск!
      process(data);
    });
  })
  .catch(error => console.log(error)); // Не поймает json() ошибку!

Каждый вложенный промис должен возвращаться наружу:

javascript
.then(response => 
  response.json().then(data => process(data)) // Теперь цепочка видит ошибки
)

Async/Await: синхронный стиль с асинхронными подвохами

Кажется, что try/catch решает все:

javascript
async function init() {
  try {
    const user = await getUser();
    const posts = await fetchPosts(user.id);
    render(posts);
  } catch (error) {
    console.error("Critical failure", error);
  }
}

Но что если:

  • Ошибка не должна ломать весь процесс? (например, фоновое обновление)
  • Надо точно знать где упало (getUser? fetchPosts?)
  • Требуется разная логика обработки для разных ошибок?

Гранулированная обработка без многоэтажных try/catch

Стратегия 1: Изоляция операций

javascript
async function loadUserProfile(userId) {
  const [user, posts] = await Promise.allSettled([
    getUser(userId).catch(error => ({ status: "failed", error })),
    getPosts(userId)
  ]);
  
  if (user.status === "rejected") {
    return handleError("USER_LOAD_FAIL", user.reason);
  }
  
  if (posts.status === "rejected") {
    log.warn("Posts unavailable, continuing");
    cache.queueRetry(user.value.id); // Фоновая попытка
  }
  
  return { profile: user.value, posts: posts.value || [] };
}

Promise.allSettled() дает контроль над каждым исходом.

Стратегия 2: Перехват + кастомные ошибки

javascript
class NetworkError extends Error { ... }
class ValidationError extends Error { ... }

async function submitForm(data) {
  try {
    validate(data); // Может выкинуть ValidationError
    await api.post("/submit", data);
  } catch (error) {
    if (error instanceof ValidationError) {
      showFormError(error.field);
    } else if (error instanceof NetworkError) {
      scheduleRetry();
    } else {
      throw error; // Прокидываем неожиданное
    }
  }
}

Глобальная страховка: последний рубеж обороны

Браузер:

javascript
// Ловим неотловленные промисы (например, забытый .catch())
window.addEventListener("unhandledrejection", event => {
  event.preventDefault();
  telemetry.trackError(event.reason);
  showUserAlert("Unexpected issue. Engineers notified.");
});

// Отлов глобальных ошибок
window.onerror = function(message, source, lineno, colno, error) {
  telemetry.log({ message, stack: error?.stack });
};

Node.js:

javascript
process.on("unhandledRejection", (reason, promise) => {
  log.fatal({ reason, promise }, "Unhandled Rejection");
  // process.exit(1) для фатальных сценариев
});

process.on("uncaughtException", error => {
  log.fatal(error, "Uncaught Exception");
  // Продолжение работы может быть опасным! 
  // Часто разумно аварийно завершаться после логгирования
});

Инженерные рекомендации практика

  1. Статус ошибок в ответах API: Явно указывайте статусы типа 4xx/5xx. Не маскируйте 500-е под 200 OK с { success: false }.
  2. Идентификация: Генерируйте errorId на сервере для трассировки:
json
{ "error": "InvalidToken", "message": "Session expired", "errorId": "123e4567" }
  1. Лог-контекст: Пишите не только error.message, но и критичные переменные для диагностики:
javascript
log.error({ err, userId, requestId }, "Payment processing failed");
  1. Контроль потока: Иногда Promise.reject() — правильный способ прервать цепочку вместо возврата флагов { error: ... }.
  2. Тестирование сбоев: Пишите тесты, имитирующие:
  • Отвал сети (nock, jest-fetch-mock)
  • Невалидные API-ответы
  • Таймауты (sinon.useFakeTimers)

Итоговые принципы

Четкие границы ответственности: Модуль/функция должны явно декларировать, какие ошибки обрабатывают внутри, какие пробрасывают выше.
Максимальная контекстуализация: Ошибка без стека вызовов, параметров запроса и ID пользователя — бесполезна.
Защита против тихого краха: Механизмы глобального перехвата — критичны для production.
Информативность: "Something went wrong" в UI допустимо только в паре с тикетом в системе мониторинга.

Обработка ошибок — это проектирование системы на выживание. Сломанный биллинг-модуль не должен валить весь backend. Авторизованная пользовательская сессия обязана выходить из ошибочного состояния хотя бы через логаут. За одиннадцать лет работы с JavaScript я видел, что грамотная обработка ошибок отделяет системы, которые падают еженощно, от тех, которые десятилетиями обслуживают миллионы.