Часть 1: Обработка ошибок в Node.js - Фундамент и архитектура

mr. Cooper 20 часов назад Веб-разработка
Часть 1: Обработка ошибок в Node.js - Фундамент и архитектура

Вы когда-нибудь просыпались в 3 часа ночи от сообщения "Сервер упал"? Я - да. И каждый раз причиной была одна и та же история: необработанная ошибка.

Эта статья - не теория из учебников. Это выжимка из реальных продакшен-проектов: от стартапов до крупного e-commerce. В первой части мы разберем фундамент обработки ошибок, без которого весь ваш код - просто пороховая бочка.

В части 1 вы узнаете:

  • Как разделять операционные и программистские ошибки

  • Как создать иерархию классов ошибок с поддержкой error.cause (Node.js 16+)

  • Как настроить централизованный обработчик ошибок в Express/Fastify

  • Почему try/catch - это ещё не всё

  • Как правильно работать с ресурсами через finally

1. Разделяйте операционные и программистские ошибки

Это самое важное различие в Node.js. Если вы его не понимаете - вы будете гадать, почему сервер падает.

Тип ошибки

Пример

Действие

Операционные (Operational Errors)

- Файл не найден

- База данных не отвечает

- Невалидный пользовательский ввод

- Таймаут сети

Можно обработать и продолжать работу

Программистские (Programmer Errors)

- Cannot read property of undefined

- TypeError: x is not a function

- ReferenceError: variable is not defined

- UnhandledPromiseRejection

Немедленный краш и перезапуск

// ❌ ПЛОХО: обрабатываем всё одинаково
try {
  const user = await getUserById(id);
  const profile = await getProfile(user.id); // если user null - ошибка программиста
} catch (err) {
  console.log('Что-то пошло не так', err);
  // Но user === null - это наша ошибка кодинга, мы НЕ должны её игнорировать!
}

// ✅ ХОРОШО: разделяем типы
try {
  const user = await getUserById(id);
  if (!user) {
    throw new OperationalError('USER_NOT_FOUND', 'Пользователь не найден');
  }
  const profile = await getProfile(user.id);
} catch (err) {
  if (err instanceof OperationalError) {
    // Можно обработать: вернуть 404, показать уведомление
    return res.status(404).json({ error: err.message });
  }
  // Программистская ошибка - падаем и пишем в лог
  logger.fatal(err, 'Программистская ошибка');
  process.exit(1); // или делегируем process.on('uncaughtException')
}

Почему это важно:

  • Вы не будете падать на том, что можно было обработать

  • Вы не будете игнорировать то, что критично

  • Ваш мониторинг перестанет флудить ложными срабатываниями

2. Создайте иерархию классов ошибок с поддержкой error.cause

Централизованные классы ошибок - это must-have. Без них вы будете разбираться в хаосе { status, message, code }.

// errors.js
class AppError extends Error {
  constructor(message, code, statusCode = 500, options = {}) {
    super(message, { cause: options.cause }); // 🔥 error.cause (Node.js 16+)
    this.code = code;
    this.statusCode = statusCode;
    this.isOperational = true;
    this.correlationId = options.correlationId || null;
    this.timestamp = new Date().toISOString();
    Error.captureStackTrace(this, this.constructor);
  }
}

class ValidationError extends AppError {
  constructor(message, field, options = {}) {
    super(message, 'VALIDATION_ERROR', 400, options);
    this.field = field;
    this.errors = options.errors || [];
  }
}

class AuthenticationError extends AppError {
  constructor(message = 'Необходима авторизация', options = {}) {
    super(message, 'UNAUTHORIZED', 401, options);
  }
}

class AuthorizationError extends AppError {
  constructor(message = 'Доступ запрещён', options = {}) {
    super(message, 'FORBIDDEN', 403, options);
  }
}

class NotFoundError extends AppError {
  constructor(resource = 'Ресурс', options = {}) {
    super(`${resource} не найден`, 'NOT_FOUND', 404, options);
  }
}

class DatabaseError extends AppError {
  constructor(message, options = {}) {
    super(message, 'DATABASE_ERROR', 503, options);
  }
}

class RateLimitError extends AppError {
  constructor(message = 'Превышен лимит запросов', options = {}) {
    super(message, 'RATE_LIMIT', 429, options);
  }
}

class ConfigError extends AppError {
  constructor(message, options = {}) {
    super(message, 'CONFIG_ERROR', 500, options);
    this.isOperational = true;
  }
}

class ExternalApiError extends AppError {
  constructor(message, options = {}) {
    super(message, 'EXTERNAL_API_ERROR', 503, options);
  }
}

// Использование с error.cause
try {
  const user = await findUser(id);
  if (!user) {
    throw new NotFoundError('Пользователь');
  }
} catch (err) {
  // Сохраняем оригинальную ошибку в cause
  throw new DatabaseError('Ошибка запроса к БД', { 
    cause: err,
    correlationId: req.correlationId 
  });
}

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

  • Все ошибки имеют единую структуру

  • Оригинальная ошибка сохраняется в cause

  • Легко расширять новыми типами

3. Используйте централизованный обработчик ошибок

Один обработчик на всё приложение - это ключ к чистоте.

// Express
app.use((err, req, res, next) => {
  // 1. Логируем ошибку с контекстом запроса
  logger.error({
    error: err.message,
    stack: err.stack,
    code: err.code,
    method: req.method,
    url: req.url,
    ip: req.ip,
    userId: req.user?.id,
    correlationId: req.correlationId || err.correlationId,
    body: sanitizeLog(req.body),
    query: req.query,
    params: req.params,
  });

  // 2. Если ошибка операционная - отправляем клиенту понятный ответ
  if (err.isOperational) {
    const response = {
      success: false,
      error: {
        code: err.code || 'INTERNAL_ERROR',
        message: err.message,
        ...(err.field && { field: err.field }),
        ...(err.errors && { details: err.errors }),
      },
      timestamp: err.timestamp || new Date().toISOString(),
      correlationId: req.correlationId || err.correlationId,
    };
    
    return res.status(err.statusCode || 500).json(response);
  }

  // 3. Если программистская - отправляем общий ответ
  res.status(500).json({
    success: false,
    error: {
      code: 'INTERNAL_SERVER_ERROR',
      message: 'Внутренняя ошибка сервера',
      correlationId: req.correlationId,
    },
    timestamp: new Date().toISOString(),
  });

  // 4. В программистских ошибках - падаем
  // но лучше это делать через process.on('uncaughtException')
});

// Fastify
fastify.setErrorHandler((error, request, reply) => {
  logger.error({
    error: error.message,
    stack: error.stack,
    code: error.code,
    method: request.method,
    url: request.url,
    ip: request.ip,
    correlationId: request.correlationId,
  });
  
  if (error.isOperational) {
    reply.status(error.statusCode || 500).send({
      success: false,
      error: { 
        code: error.code, 
        message: error.message,
        ...(error.field && { field: error.field }),
      },
      correlationId: request.correlationId,
      timestamp: new Date().toISOString(),
    });
  } else {
    reply.status(500).send({
      success: false,
      error: { 
        code: 'INTERNAL_ERROR', 
        message: 'Internal Server Error' 
      },
      correlationId: request.correlationId,
      timestamp: new Date().toISOString(),
    });
  }
});

4. Никогда не скрывайте ошибки в catch без обработки

Грех 90% разработчиков.

// ❌ ПЛОХО: ошибка проглочена
try {
  await criticalOperation();
} catch (err) {
  // Тихо умираем... никто не узнает
}

// ❌ ПЛОХО: просто логируем и идём дальше
try {
  await criticalOperation();
} catch (err) {
  console.log('Ошибка, но мы идём дальше');
  // А вдруг criticalOperation нужна для дальнейшего кода?
}

// ✅ ХОРОШО: либо пробрасываем, либо обрабатываем осознанно
try {
  await criticalOperation();
} catch (err) {
  // Вариант 1: пробрасываем выше с обогащённым контекстом
  throw new DatabaseError(`Не удалось выполнить операцию: ${err.message}`, {
    cause: err
  });

  // Вариант 2: если операция опциональна - обрабатываем и продолжаем
  logger.warn('Опциональная операция не удалась, но мы продолжаем', err);
  // Но тогда убедитесь, что дальнейший код может работать без неё
}

5. Используйте finally для очистки ресурсов

Ресурсы должны освобождаться всегда, даже при ошибке.

let connection = null;
try {
  connection = await db.connect();
  const data = await connection.query(sql);
  return data;
} catch (err) {
  throw new DatabaseError(err.message, { cause: err });
} finally {
  // Всегда закрываем, даже если ошибка
  if (connection) {
    await connection.close();
    logger.debug('Соединение закрыто');
  }
}

6. Настройте глобальные обработчики uncaughtException и unhandledRejection

Это ваш последний рубеж.

// app.js

// Обработчик необработанных ошибок (синхронных)
process.on('uncaughtException', (err) => {
  console.error('🔴 UNCAUGHT EXCEPTION:', err);
  console.error('Stack:', err.stack);

  logger.fatal({
    msg: 'Uncaught Exception',
    error: err.message,
    stack: err.stack,
    correlationId: err.correlationId || null,
  });

  // Даём время на закрытие соединений, затем перезапускаемся
  setTimeout(() => {
    process.exit(1);
  }, 1000);
});

// Обработчик необработанных ошибок в промисах
process.on('unhandledRejection', (reason, promise) => {
  console.error('🔴 UNHANDLED REJECTION:', reason);
  console.error('Promise:', promise);

  logger.fatal({
    msg: 'Unhandled Rejection',
    error: reason?.message || String(reason),
    stack: reason?.stack,
  });

  // В новых версиях Node.js это будет приводить к crash
  // Лучше не полагаться на это и обрабатывать всё на уровне кода
});

// Graceful shutdown при exit
process.on('SIGTERM', () => {
  logger.info('SIGTERM received, shutting down gracefully...');
  server.close(() => {
    logger.info('Server closed');
    process.exit(0);
  });
});

process.on('SIGINT', () => {
  logger.info('SIGINT received, shutting down gracefully...');
  server.close(() => {
    logger.info('Server closed');
    process.exit(0);
  });
});

7. Всегда обрабатывайте ошибки в промисах

Никогда не оставляйте промис без .catch().

// ❌ ПЛОХО: ошибка приведёт к unhandledRejection
getUser(id).then((user) => {
  console.log(user);
});

// ✅ ХОРОШО: всегда есть catch
getUser(id)
  .then((user) => console.log(user))
  .catch((err) => {
    logger.error('Ошибка получения пользователя', err);
    // Можно пробросить или обработать
  });

// ✅ ЕЩЁ ЛУЧШЕ: в async/await с try/catch
const user = await getUser(id).catch((err) => {
  logger.error(err);
  throw new NotFoundError('Пользователь', { cause: err });
});

8. Используйте Promise.allSettled вместо Promise.all

// ❌ ПЛОХО: одна ошибка убивает всё
const [users, products, orders] = await Promise.all([
  fetchUsers(),
  fetchProducts(),
  fetchOrders(),
]);

// ✅ ХОРОШО: получаем результаты всех, даже с ошибками
const results = await Promise.allSettled([
  fetchUsers(),
  fetchProducts(),
  fetchOrders(),
]);

const users = results[0].status === 'fulfilled' ? results[0].value : [];
const products = results[1].status === 'fulfilled' ? results[1].value : [];
const orders = results[2].status === 'fulfilled' ? results[2].value : [];

// Логируем ошибки частичных неудач
results.forEach((result, index) => {
  if (result.status === 'rejected') {
    logger.error(`Запрос #${index} упал:`, result.reason);
  }
});

9. Делайте таймауты для Promise

Бесконечные промисы - это плохо.

function promiseWithTimeout(promise, ms = 5000, errorMessage = 'Таймаут') {
  return Promise.race([
    promise,
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error(`${errorMessage} (${ms}ms)`)), ms)
    ),
  ]);
}

// Использование
try {
  const data = await promiseWithTimeout(
    fetch('/api/data'),
    3000,
    'Долгий запрос к API'
  );
} catch (err) {
  if (err.message.includes('Таймаут')) {
    logger.warn('Запрос занял больше 3 секунд');
  }
}

// Для библиотек с нативной поддержкой
const response = await fetch('/api/data', {
  signal: AbortSignal.timeout(5000),
});

10. Используйте util.promisify с умом

const util = require('util');
const fs = require('fs');

// ❌ ПЛОХО: потеряли контекст ошибки
const readFile = util.promisify(fs.readFile);

// ✅ ХОРОШО: обёртка с контекстом
async function safeReadFile(path) {
  try {
    const content = await fs.promises.readFile(path, 'utf8');
    return content;
  } catch (err) {
    throw new OperationalError(
      `Не удалось прочитать файл ${path}`, 
      'FILE_READ_ERROR', 
      500,
      { cause: err }
    );
  }
}

Итог части 1

Ключевые выводы:

  1. Разделяйте операционные и программистские ошибки - фундамент всей системы

  2. Создайте иерархию классов ошибок с поддержкой error.cause

  3. Настройте централизованный обработчик ошибок в Express/Fastify

  4. Никогда не глотайте ошибки в catch без обработки

  5. Освобождайте ресурсы через finally

  6. Настройте глобальные обработчики uncaughtException и unhandledRejection

  7. Всегда обрабатывайте ошибки в промисах

  8. Используйте Promise.allSettled для частичной устойчивости

  9. Добавляйте таймауты для промисов

  10. Оборачивайте старые API с сохранением контекста

Комментарии

Пока нет комментариев. Будьте первым, кто напишет.

Чтобы оставить комментарий, войдите в аккаунт.

Похожие статьи