Эффективная обработка ошибок в Node.js: от колбэков до async/await

Будучи разработчиком Node.js, вы неизбежно столкнётесь с ошибками. Но как их обрабатывать ‒ не просто ловить, а делать это системно, обеспечивая отказоустойчивость приложения и содержательные логи? Этот вопрос не так прост, как кажется на первый взгляд, особенно с учётом эволюции подходов от колбэков через промисы к современному async/await.

Природа ошибок в асинхронном мире

В Node.js ошибки делятся на три фундаментальные категории:

  1. Операционные ошибки (сбой во время выполнения: сетевые проблемы, ошибки ввода-вывода)
  2. Ошибки программиста (баги в коде: неопределённые переменные, ошибки логики)
  3. Преднамеренные ошибки (контролируемые исключения для бизнес-логики)

Синхронные ошибки можно перехватывать через try/catch:

javascript
function parseJSONSync(input) {
    try {
        return JSON.parse(input);
    } catch (err) {
        // Чистый пример ловли синхронной ошибки
        logger.error('Parse failed', err);
        return null;
    }
}

Но асинхронный код нарушает эту простоту. Рассмотрим эволюцию подходов.

Колбэки: паттерн "error-first"

Традиционный подход Node.js ‒ колбэки с первым аргументом-ошибкой:

javascript
const fs = require('fs');

fs.readFile('/nonexistent.txt', (err, data) => {
    if (err) {
        // Обязательно проверка err
        console.error('Ошибка чтения:', err.message);
        return;
    }
    console.log(data.toString());
});

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

  • Никогда не игнорируйте ошибку! Проблема if (err) throw err; в асинхронном колбэке ‒ исключение приведёт к краху процесса. Альтернатива ‒ безопасный проброс:

    javascript
    function readFileAsync(path, callback) {
        fs.readFile(path, (err, data) => {
            if (err) return callback(err); // Пробрасываем
            // Обработка данных...
            callback(null, processedData);
        });
    }
    
  • Потеря стека вызовов: Время выполнения колбэка стирает исходный стек. Решение ‒ создание пользовательских ошибок:

    javascript
    fs.readFile('config.json', (err, data) => {
        if (err) {
            const customErr = new Error('Config load failed');
            customErr.originalError = err;
            customErr.file = 'config.json';
            return callback(customErr);
        }
        // ...
    });
    

Промисы: мгновенная гибель и цепной проброс ошибок

Промисы предлагают более структурированный подход с .catch():

javascript
const fetch = require('node-fetch');

fetch('https://api.example.com/data')
    .then(response => {
        if (!response.ok) throw new Error('HTTP error');
        return response.json();
    })
    .then(data => processData(data))
    .catch(err => {
        // Перехват ВСЕХ ошибок в цепочке
        console.error('Fetch pipeline failed:', err);
    });

Особенности:

  • Ошибки автоматически пробрасываются в ближайший catch

  • throw внутри обработчика .then() преобразуется в отклонённый промис

  • Коварная проблема: забытый .catch():

    javascript
    // Опасный код!
    asyncFunction()
        .then(data => console.log(data));
    
    // Непойманное обещание упадёт позднее
    

Всегда завершайте цепочку промисов обработчиком ошибок.

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

Современный подход вводит async/await:

javascript
async function loadUserData(userId) {
    try {
        const user = await fetchUser(userId);  
        const profile = await fetchProfile(user.profileId);
        return { user, profile };
    } catch (err) {
        // Перехватывает ЛЮБУЮ ошибку в блоке try
        console.error('User data load failed for', userId, err);
        throw new UserDataError(userId, err);
    }
}

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

  • Ошибки обрабатываются как в синхронном коде через try/catch
  • Сохранение стека вызовов
  • Возможность аннотирования ошибок контекстом

Опасные заблуждения:

  1. Иллюзия отлова всех ошибок:

    javascript
    async function dangerousExample() {
        const promise = fetchData();
        // Длинная операция...
        await new Promise(resolve => setTimeout(resolve, 1000));
        const data = await promise; // Ошибка здесь НЕ поймается
    }
    
    // Правильно: обернуть все асинхронные действия сразу
    async function safeExample() {
        const data = await fetchData();
        // ...
    }
    
  2. Пропущенный await при отлове:

    javascript
    async function updateUser(user) {
        try {
            return saveUser(user); // Пропущенный await!
        } catch (err) {
            // Этот блок НЕ выполнится при ошибке saveUser
        }
    }
    

Продвинутые методики обработки

Агрегирование ошибок

При параллельном выполнении Promise.all() прерывается при первой ошибке. Для получения всех ошибок используйте Promise.allSettled():

javascript
async function batchProcessing(items) {
    const results = await Promise.allSettled(
        items.map(item => processItem(item))
    );
    
    const errors = results
        .filter(r => r.status === 'rejected')
        .map(r => r.reason);
        
    if (errors.length > 0) {
        // Логируем все ошибки единообразно
        throw new AggregateError(errors, 'Batch processing failed');
    }
    
    return results.map(r => r.value);
}

Трансформация ошибок

Создавайте иерархию ошибок для улучшения диагностики:

javascript
class AppError extends Error {
    constructor(message, originalError) {
        super(message);
        this.originalError = originalError;
        this.timestamp = new Date();
    }
}

class DatabaseError extends AppError {
    constructor(originalError, query) {
        super('Database operation failed', originalError);
        this.query = query;
        this.errorCode = originalError.code;
    }
}

// Использование
try {
    await db.query('SELECT * FROM missing_table');
} catch (err) {
    throw new DatabaseError(err, 'SELECT');
}

Централизованный перехват

В приложениях Express реализуйте middleware для консолидации обработки:

javascript
app.use(async (err, req, res, next) => {
    // Логирование со структурированным контекстом
    logger.error({
        error: err.message,
        stack: err.stack,
        httpMethod: req.method,
        path: req.path,
        user: req.user?.id
    });
    
    // Преобразование в клиентский формат
    const statusCode = err instanceof ClientError ? 400 : 500;
    res.status(statusCode).json({
        error: err.publicMessage || 'Internal server error'
    });
});

Золотые правила практической обработки ошибок

  1. Всегда оборачивайте async операции в try/catch: Избегайте соблазна пропускать обработку "на потом".
  2. Обеспечьте контекст: Аннотируйте ошибку подробностями (параметры запроса, идентификаторы). Без контекста ошибка ‒ просто шум.
  3. Разделяйте ответственность: Не смешивайте обработку ошибок с бизнес-логикой. Выделите централизованный механизм для логирования и проброса.
  4. Используйте сообщения для заказчика: Пользовательские ошибки должны содержать понятное описание проблемы. Технические детали ‒ только для логов.
  5. Необработанные промахи реагируйте мгновенно: Обязательно вешайте обработчик unhandledRejection:
    javascript
    process.on('unhandledRejection', (reason, promise) => {
        console.error('НЕОБРАБОТАННОЕ ОТКЛОНЕНИЕ ПО ОБЕЩАНИЮ:', reason);
        // Экстренное завершение или перезагрузка
        process.exit(1);
    });
    

Эффективная обработка ошибок ‒ не модуль из npm, который можно подключить. Это архитектурный подход, требующий постоянного внимания. Начните с дисциплинированного использования try/catch в async-функциях, добавляйте контекст к ошибкам, используйте структурированное логирование ‒ и ваши сервисы превратятся из хрустальных башен в устойчивые форты, способные выдерживать реалии продакшена.