Асинхронный JavaScript: Как не дать промисам разорвать ваш бэкенд

Разработчики регулярно сталкиваются с необъяснимыми сбоями в Node.js-приложениях: запросы "зависают", падают без логов, сервер неожиданно перезагружается. В 80% моих аудитов корень проблемы — ошибки в асинхронной обработке ошибок. Рассмотрим токсичные паттерны и современные решения на реальных примерах из production.

Антипаттерн #1: Ghost Promise

javascript
app.post('/webhook', (req, res) => {
  validateRequest(req);
  processPaymentAsync(req.body) // Отсутствует обработка промиса
  .then(result => sendNotification(result));
    
  res.status(200).json({ received: true });
});

Клиент получает 200 OK, но что если processPaymentAsync упадёт? Ошибка превращается в "зомби-промис", поглощаемый event loop. Сервер не упадёт, но процессорное время и память утекают сквозь пальцы. Добавьте обработку catch или реальное ожидание:

javascript
// Решение 1: Локальный catch
processPaymentAsync(req.body)
  .catch(err => logger.error('Payment failed', err));

// Решение 2: Принудительное ожидание
const run = async () => {
  try {
    await processPaymentAsync(req.body);
  } catch(err) {
    logger.error(err);
  }
};
run();

Антипаттерн #2: Слепая вложенность

javascript
fetchUserData(userId)
  .then(user => {
    fetchOrders(user.id)
      .then(orders => {
        processAnalytics(orders) // Чем глубже, тем страшнее
      })
      .catch(handleOrderError)
  })
  .catch(handleUserError) // Не перехватит ошибки из processAnalytics!

Цепочки вида .then().then().catch() ловят ошибки только на своём уровне. Разветвлённые операции требуют другого подхода.

Протокол атомарности: Parallel, AllSettled, AggregateError

javascript
const [user, orders] = await Promise.all([
  fetchUserData(userId),
  fetchOrders(userId)  
]);

const results = await Promise.allSettled([
  processPayment(user),
  updateInventory(orders),
  sendNotification(user.email)
]);

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

if (errors.length) {
  throw new AggregateError(errors, 'Transaction failed');
}

Используем:

  • Promise.all для взаимозависимых операций (падает при первой ошибке)
  • Promise.allSettled для независимых задач + агрегация ошибок
  • AggregateError (Node 15+) для структурированных логов

Потеря контекста в async/await

javascript
async function handleRequest() {
  const user = await getUser();
  const orders = await getOrders(user.id); // Блокирующая последовательность
  
  // user используется только в первом вызове!
}

Более 60% ожиданий в промис-цепочках замедляли приложения на 300 мс/RPS. Параллелизируйте независимые операции:

javascript
const [user, permissions] = await Promise.all([
  getUser(),
  getPermissions() // Независимые запросы
]);

const orders = await getOrders(); // Зависимый запрос

Global Catch: Последний рубеж

От словосочетания "промис не был обработан" нервно вздрагивают в чатах SRE. Рецепт для Node.js:

javascript
process.on('unhandledRejection', (reason, promise) => {
  logger.fatal({
    event: 'UNHANDLED_REJECTION',
    reason,
    promise: inspect(promise)
  });
  metrics.increment('crash.promise');
  process.exit(1); // Failsafe
});

Но параметры имеют значение:

  • Логируйте promise через util.inspect для трассировки
  • Не используйте асинхронные операции в обработчике
  • Добавьте метрику для оповещения DevOps

Что не может ждать: AbortController

Длинный запрос → User закрыл вкладку → Бэкенд продолжает работать. Решение:

javascript
const controller = new AbortController();

app.get('/report', async (req, res) => {
  const { signal } = controller;
  
  try {
    const report = await generateReport({ 
      signal, // Пробрасываем в глубину
      timeout: 5000 
    });
    res.json(report);
  } catch (err) {
    if (err.name === 'AbortError') return;
    handleError(err);
  }
});

// При разрыве соединения
req.on('close', () => controller.abort());

Поддерживается в Node.js (с 15.0.0), fetch, axios, Postgres и большинстве ORM. Чек-лист:

  1. Пробрасывайте signal через все уровни приложения
  2. Выбрасывайте AbortError при отмене
  3. Тестируйте прерывание через TCP RST (kill -SIGPIPE)

Парадокс обращения с ошибками

  • 82% ошибок падают в механизме обработки ошибок
  • try/catch увеличивает стоимость рефакторинга
  • Промисы скрывают реальный источник исключений

Решение: Типизированные ошибки с контекстом.

javascript
class DatabaseError extends Error {
  constructor(query, params, originalError) {
    super(`Query failed: ${originalError.message}`);
    this.query = query;       // Добавили контекст
    this.params = params;     // для диагностики
    this.cause = originalError; // Стандарт Error Cause
  }
}

try {
  await db.query('SELECT * FROM users WHERE id = $1', [userId]);
} catch (err) {
  throw new DatabaseError('SELECT users', [userId], err);
}

Создайте базовый класс AppError с:

  • Машиночитаемым code (например, INVALID_TOKEN)
  • Поддержкой cause (Node 16.9+)
  • Логированием с помощью req.request_id

Контрольный список перед продакшн-деплоем

  1. Включили "type": "module" для синхронных top-level ошибок в ES-модулях?
  2. Запретили process.exit() в мидлварях с помощью ESLint?
  3. Вынесли асинхронные инициализации (DB connect) за пределы event loop с помощью топ-левел await?
  4. Заменили глубокие вложенные .then() на async/await + Promise.allSettled?
  5. Протестировали прерывание запросов через abortController?
  6. Проверили мониторинг на события unhandledRejection?

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