Потерянные исключения: Почему ваш async/await код может игнорировать ошибки и как это исправить

Асинхронный код на JavaScript претерпел эволюцию от ада колбэков через чистилище промисов к элегантности async/await. Но за кажущейся простотой синтаксиса скрывается коварная ловушка: незаметное проглатывание исключений. Ваш код не падает, приложение выглядит работоспособным, а критические ошибки тихо исчезают в пустоте. Рассмотрим реальные сценарии и стратегии противодействия.

Проблемный паттерн: Неявное поглощение исключений

javascript
// Проблемный код: ошибка не обрабатывается!
async function processOrder(orderId) {
    const order = await fetchOrder(orderId); // Может выбросить ошибку
    updateInventory(order.items);            // Может выбросить ошибку
    await sendConfirmationEmail(order.user); // Может выбросить ошибку
}

// Где-то в другом месте:
processOrder(123); // Ошибка исчезнет без следа

Почему это фатально:

  1. Исключения в processOrder преобразуются в отклоненные промисы
  2. Вызов функции без обработки catch приводит к необработанной ошибке промиса
  3. В Node.js это завершит процесс
  4. В браузерах пользователь увидит разбитую логику без диагностики
  5. Нет журналирования, нет отката транзакций, нет уведомлений

Стратегия 1: Явная перехват с помощью try/catch

Базовый, но надежный метод:

javascript
async function createUser(userData) {
    try {
        const validation = validateUserData(userData); // Синхронная ошибка
        const dbResponse = await db.insert(userData);  // Асинхронная ошибка
        await queueWelcomeEmail(userData.email);
    } catch (error) {
        // Комплексное журналирование с контекстом
        logger.error('User creation failed', { 
            error, 
            userData: redactSensitiveFields(userData)
        });
        
        // Передаем адаптированную ошибку для middleware
        throw new AppError('User creation failed', { 
            cause: error,
            code: 'USER_CREATION_FAILURE'
        });
    }
}

Критичные нюансы:

  • Синхронные ошибки внутри async функций генерируют отклоненные промисы автоматически
  • return из блока catch вернет успешное разрешение промиса (часто антипаттерн)
  • Протоколирование в catch должно быть неблокирующим: используйте асинхронные логгеры или очередь сообщений
  • Всегда конвертируйте в типизированные ошибки: библиотечные ошибки не имеют бизнес-контекста

Стратегия 2: Глобальные обработчики — ответственность джентльмена

javascript
// Node.js
process.on('unhandledRejection', (reason, promise) => {
    telemetry.captureException(reason);
    logger.fatal('Uncaught async error', { reason });
    // Не принуждаем к завершению в продакшене - оставляем возможность восстановления
});

// Браузер
window.addEventListener('unhandledrejection', (event) => {
    event.preventDefault();
    monitoring.report(event.reason);
});

Но ограниченно надежно:

  • Невозможно применить восстановление контекста вызова
  • Нет информации о состоянии приложения
  • Часто дублируется с логикой сервисов мониторинга
  • Должен быть крайним рубежом, а не основным методом

Стратегия 3: Адский промис: Расширенный контроль

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

javascript
async function executeWithRetry(operation, { retries = 2 } = {}) {
    let attempt = 0;
    
    while (attempt <= retries) {
        try {
            return await operation();
        } catch (error) {
            attempt++;
            
            if (!isRetryableError(error) || attempt > retries) {
                throw error; // Выбрасывать для вышестоящих обработчиков
            }
            
            await delay(100 * Math.pow(2, attempt));
        }
    }
}

// Использование:
executeWithRetry(() => paymentService.charge(amount))
    .catch(handlePaymentFailure);

Особенности реализации:

  • Экспоненциальная отсрочка снижает нагрузку при сбое системы
  • isRetryableError определает только повторяемы ошибки (сетевая забота, 5xx)
  • Возвращаем результат operation(), сохраняя семантику вызова

Параллельные операции: Найди неудачника в толпе

Promise.all прервется при первой ошибке:

javascript
// Подход с Promise.allSettled для комплексного анализа
async function fetchDashboardData() {
    const results = await Promise.allSettled([
        fetchUser(),
        fetchPermissions(),
        fetchNotifications()
    ]);

    const errors = results.filter(r => r.status === 'rejected');
    
    if (errors.length > 0) {
        errors.forEach(e => 
            logger.warn('Partial data failure', e.reason));
    }

    return {
        user: getResult(results[0]),
        permissions: getResult(results[1]),
        notifications: getResult(results[2])
    };
}

// Вспомогательная функция для работы с неопределённым состоянием
function getResult(result) {
    return result.status === 'fulfilled' ? result.value : null;
}

Решение для задач требовательных к завершению:

javascript
async function processBatch(batch) {
    const promises = batch.map(order => 
        processOrder(order).catch(e => {
            recordFailedOrder(order.id, e);
            return null; // Возвращаем отменяемую операцию
        })
    );

    return (await Promise.all(promises)).filter(Boolean);
}

Архитектурные решения: Создание структуры ошибок

Стандартизация модели ошибок:

javascript
class DomainError extends Error {
    constructor(message, { cause, code, retryable } = {}) {
        super(message);
        this.code = code ?? 'GENERIC_ERROR';
        this.retryable = retryable ?? false;
        if (cause) this.cause = cause;
    }
}

class DatabaseError extends DomainError {
    constructor(message, options) {
        super(message, { ...options, code: 'DB_OPERATION_FAILED' });
    }
}

// Применение:
try {
    await db.query('...');
} catch (error) {
    throw new DatabaseError('Failed to execute query', {
        cause: error,
        retryable: true
    });
}

Преимущества:

  • CATALOG_ERRORS для стандартизации ответов API
  • Централизованный перехват в middleware Express/Koa/Fastify
  • Возможность автоматической стратегии повтора
  • Интеграция с системами мониторинга (Sentry, OpenTelemetry)

Заключение: Философия устойчивости

Обработка асинхронных ошибок — не рутинное примечание при программировании, а фундаментальная часть архитектуры:

  1. Никогда не доверяйте молчанию: Незахваченные отклонения сигнализируют о неисправности системы
  2. Контекст убивает неопределенность: Всегда привязывайте ошибки к бизнес-операциям
  3. Различайте последствия: Сбои сетей требуют повторных попыток; ошибки валидации требуют немедленного прерывания работы
  4. Проектируйте для отслеживания: Логируйте с применением структурных данных; используйте уникальные коды ошибок

Сломайте цепь безмолвных сбоев. Точная обработка асинхронных ошибок отличает хрупкий прототип от промышленного ПО. Ваши ошибки должны завершать работу с оглушительным грохотом, а не умирать шепотом.