Асинхронные ошибки в JavaScript: Как не потерять исключения между then() и await

Рассмотрим сценарий: ваше Node.js-приложение падает в продакшене с ошибкой UnhandledPromiseRejection. Логи показывают, что кто-то не обработал отклоненный промис, но в коде повсюду расставлены try/catch и .catch(). Знакомо? Современный JavaScript дает мощные инструменты для работы с асинхронностью, но ошибки в их использовании остаются одними из самых коварных.

Почему промисы «протекают»

Распространенная иллюзия безопасности возникает при цепочечном синтаксисе:

javascript
fetchData()
  .then(validate)
  .then(process)
  .catch(logError); // Магический спасатель

Но что если validate выбросит синхронную ошибку? В классических промисах это приводит к неотловленному исключению. Современные движки автоматически превращают синхронные ошибки в отклоненные промисы, но только в теле промис-колбэка. Рассмотрим опасный фрагмент:

javascript
const unstablePromise = new Promise(() => {
  throw new Error('Синхронная бомба');
});

// Сработает .catch?
unstablePromise.catch(console.error); // Не сработает: промис сразу rejected

Здесь .catch() регистрируется уже после отклонения промиса. Решение — всегда оборачивать инициализацию промисов в try/catch или использовать async функции, которые автоматически оборачивают код в промис.

Асинхронные пробелы в async/await

Переход на async/await создает ложное чувство защищенности. Типичная ловушка:

javascript
async function processOrder(orderId) {
  try {
    const order = await fetchOrder(orderId);
    sendConfirmation(order.user.email); // Синхронная функция
  } catch (e) {
    // Поймает ли ошибку из sendConfirmation?
  }
}

Если sendConfirmation выбросит синхронную ошибку, catch ее перехватит — await отсутствует, но весь код внутри async функции выполняется в промисном контексте. Однако другая ситуация возникает при параллельных операциях:

javascript
async function updateDashboard() {
  try {
    const [user, orders] = await Promise.all([
      fetchUser(), // resolved
      fetchOrders() // rejected
    ]);
    
    renderUI(user); // Не выполнится
  } catch (e) {
    // Поймает ошибку из fetchOrders
  }
}

При использовании Promise.all() первая ошибка в массиве промисов приводит к немедленному отклонению всей группы. Но что если нужно обработать частичные результаты?

Паттерн обработки для сложных сценариев

Для задач, где критически важна обработка всех подопераций, даже с ошибками, используйте Promise.allSettled с фильтрацией:

javascript
async function robustDataLoader() {
  const results = await Promise.allSettled([
    fetchMetrics(),
    fetchUserData(),
    fetchThirdPartyService()
  ]);

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

  if (errors.length > 0) {
    await logToSentry(errors); // Отправка всех ошибок
  }

  return results.map(r => 
    r.status === 'fulfilled' ? r.value : fallbackData()
  );
}

Этот подход гарантирует, что:

  1. Все операции завершаются (успешно или нет)
  2. Ошибки централизованно логируются
  3. Клиент получает данные или fallback-значения

Контекстные ошибки: Больше чем message

Стандартный Error часто недостаточен для отладки. Создавайте доменно-специфичные классы:

javascript
class DatabaseError extends Error {
  constructor(query, params, originalError) {
    super(`DB failure: ${originalError.message}`);
    this.query = query;
    this.params = params; // Чувствительные данные должны быть обезличены
    this.code = originalError.code;
  }
}

async function queryDB(sql, params) {
  try {
    return await pool.query(sql, params);
  } catch (e) {
    throw new DatabaseError(sql, params, e);
  }
}

В обработчике ошибок верхнего уровня:

javascript
process.on('unhandledRejection', (reason) => {
  if (reason instanceof DatabaseError) {
    metrics.increment('db.failure');
    logger.error('Database failure', {
      query: redactSensitive(reason.query),
      code: reason.code
    });
  }
  process.exit(1);
});

Неочевидные источники утечек

Обработка ошибок — это не только try/catch. Рассмотрим пример из Express:

javascript
app.get('/api/data', async (req, res) => {
  const data = await fetchData();
  res.json(data);
}); // Нет next, нет catch

Любая ошибка в fetchData приведет к необработанному отклонению промиса. Решение — middleware для обертки:

javascript
function asyncHandler(fn) {
  return (req, res, next) => 
    Promise.resolve(fn(req, res, next)).catch(next);
}

app.get('/api/data', asyncHandler(async (req, res) => {
  const data = await fetchData();
  res.json(data);
}));

Инструменты принудительной дисциплины

  1. ESLint правилами:
json
{
  "rules": {
    "no-floating-promises": "error",
    "require-await": "warn"
  }
}
  1. Node.js флаги:
text
node --unhandled-rejections=strict app.js
  1. Нативные модули: Использование async_hooks для трейсинга незавершенных операций.

Главная мысль: в асинхронном JavaScript обработка ошибок не должна быть догадкой. Она требует явной архитектуры — от проектирования отдельных функций до настройки глобальных обработчиков. Паттерн, который работает: всегда предполагайте, что любая асинхронная операция может завершиться ошибкой, и предусматривайте пути восстановления, а не только логирование.

text