Часть 3: Обработка ошибок в Node.js - Продакшен-готовность

mr. Cooper 1 час назад Веб-разработка
Часть 3: Обработка ошибок в Node.js - Продакшен-готовность

Ваш код работает локально. Всё падает и поднимается. Но в продакшене - совсем другая история. Внешний API упал, Redis перестал отвечать, пользователь нажал "отправить" и ушёл, а запрос висит 10 минут.

В этой части - всё, чтобы ваш сервер не падал при ошибках, а пользователь не замечал проблем.

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

  • Как настроить graceful shutdown

  • Как реализовать retry-механизмы для внешних сервисов

  • Как защититься от каскадных отказов через Circuit Breaker

  • Как добавить health-чеки для мониторинга

  • Как репортить ошибки в Slack/Telegram

21. Graceful shutdown: закрывайте соединения корректно

const mongoose = require('mongoose');
const redis = require('redis');

async function gracefulShutdown() {
  logger.info('🔄 Graceful shutdown started...');
  
  // 1. Закрываем HTTP-сервер (прекращаем принимать новые запросы)
  server.close(() => {
    logger.info('📡 HTTP server closed');
  });
  
  // 2. Даём время на завершение текущих запросов
  await new Promise((resolve) => setTimeout(resolve, 5000));
  
  // 3. Закрываем соединения с БД
  if (mongoose.connection.readyState === 1) {
    await mongoose.connection.close();
    logger.info('🛢️ MongoDB disconnected');
  }
  
  // 4. Закрываем Redis
  if (redis.client?.isOpen) {
    await redis.client.quit();
    logger.info('🧩 Redis disconnected');
  }
  
  // 5. Закрываем другие соединения (Kafka, RabbitMQ, и т.д.)
  
  logger.info('✅ Graceful shutdown completed');
  process.exit(0);
}

// Перехватываем сигналы
process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);

// Ошибки на уровне процесса
process.on('uncaughtException', (err) => {
  logger.fatal(err, 'Uncaught Exception');
  gracefulShutdown();
});

process.on('unhandledRejection', (err) => {
  logger.fatal(err, 'Unhandled Rejection');
  gracefulShutdown();
});

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

  • Запросы не обрываются на полуслове

  • База данных закрывается корректно

  • Нет потерянных данных

  • Kubernetes/Pm2 могут перезапустить сервер без проблем

22. Используйте retry-механизмы для внешних сервисов

const retry = require('async-retry');

async function callExternalApiWithRetry(userId) {
  return retry(
    async (bail) => {
      try {
        const response = await axios.get(`https://api.example.com/user/${userId}`, {
          timeout: 5000,
          headers: {
            'X-Correlation-ID': ns.get('correlationId'),
          },
        });
        return response.data;
      } catch (err) {
        // Неповторяемые ошибки: 400, 401, 403, 404
        if (err.response?.status === 400 || err.response?.status === 404) {
          bail(new NotFoundError('Пользователь не найден', { cause: err }));
        }
        
        // Для российских провайдеров увеличиваем таймауты
        if (err.code === 'ETIMEDOUT' || err.code === 'ECONNRESET') {
          logger.warn({
            msg: 'External API timeout, retrying...',
            error: err.message,
            attempt: this?.attempt || 0,
            correlationId: ns.get('correlationId'),
          });
        }
        
        throw err;
      }
    },
    {
      retries: 3,
      minTimeout: 1000,
      maxTimeout: 5000,
      factor: 2,
      onRetry: (err, attempt) => {
        logger.warn(`Retry ${attempt}/3`, { 
          error: err.message,
          correlationId: ns.get('correlationId'),
        });
      },
    }
  );
}

// Использование
try {
  const user = await callExternalApiWithRetry(userId);
  return user;
} catch (err) {
  if (err instanceof NotFoundError) throw err;
  throw new ExternalApiError('Внешний сервис недоступен', { cause: err });
}

23. Circuit Breaker: защита от каскадных отказов

const CircuitBreaker = require('opossum');

const options = {
  timeout: 10000, // 10 сек (увеличен для российских провайдеров)
  errorThresholdPercentage: 50, // 50% ошибок
  resetTimeout: 60000, // 60 сек (увеличен для стабильности)
  volumeThreshold: 5, // минимум 5 запросов
};

const breaker = new CircuitBreaker(async (userId) => {
  const response = await fetch(`/api/user/${userId}`, {
    headers: {
      'X-Correlation-ID': ns.get('correlationId'),
    },
  });
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }
  return response.json();
}, options);

breaker.fallback((userId) => {
  return { 
    id: userId, 
    name: 'Unknown', 
    error: 'Service temporarily unavailable',
    timestamp: new Date().toISOString(),
  };
});

breaker.on('open', () => {
  logger.warn({
    msg: 'Circuit Breaker opened!',
    correlationId: ns.get('correlationId'),
  });
});

breaker.on('halfOpen', () => {
  logger.info({
    msg: 'Circuit Breaker half-open, testing...',
    correlationId: ns.get('correlationId'),
  });
});

breaker.on('close', () => {
  logger.info({
    msg: 'Circuit Breaker closed, service restored',
    correlationId: ns.get('correlationId'),
  });
});

// Использование
try {
  const user = await breaker.fire(userId);
  return user;
} catch (err) {
  logger.error({
    msg: 'User service failed',
    error: err.message,
    correlationId: ns.get('correlationId'),
  });
  throw new ServiceUnavailableError('User service temporarily unavailable', { cause: err });
}

Как работает Circuit Breaker:

  • Closed: всё работает, запросы идут

  • Open: сервис недоступен, запросы не идут (fallback)

  • Half-Open: проверяем, восстановился ли сервис

24. Добавьте health-чек для мониторинга

const os = require('os');

app.get('/health', async (req, res) => {
  const health = {
    status: 'ok',
    uptime: process.uptime(),
    timestamp: new Date().toISOString(),
    version: process.env.npm_package_version || '1.0.0',
    environment: process.env.NODE_ENV || 'development',
    checks: {},
    system: {
      memory: process.memoryUsage(),
      cpu: os.loadavg(),
      pid: process.pid,
    },
  };

  // Проверка MongoDB
  try {
    await mongoose.connection.db.admin().ping();
    health.checks.mongodb = { status: 'ok' };
  } catch (err) {
    health.checks.mongodb = { status: 'fail', error: err.message };
    health.status = 'degraded';
  }

  // Проверка Redis
  try {
    await redis.client.ping();
    health.checks.redis = { status: 'ok' };
  } catch (err) {
    health.checks.redis = { status: 'fail', error: err.message };
    health.status = 'degraded';
  }

  // Проверка внешнего API
  try {
    const start = Date.now();
    const response = await axios.get('https://external-api.com/ping', { timeout: 2000 });
    health.checks.externalApi = { 
      status: response.status === 200 ? 'ok' : 'fail',
      latency: Date.now() - start,
    };
  } catch (err) {
    health.checks.externalApi = { status: 'fail', error: err.message };
    health.status = 'degraded';
  }

  const isHealthy = health.status === 'ok';
  res.status(isHealthy ? 200 : 503).json(health);
});

// liveness probe (Kubernetes)
app.get('/live', (req, res) => {
  res.send('OK');
});

// readiness probe
app.get('/ready', (req, res) => {
  if (mongoose.connection.readyState === 1) {
    res.send('Ready');
  } else {
    res.status(503).send('Not Ready');
  }
});

25. Репортинг ошибок: сделайте пользователю больно, но честно

class ErrorReporter {
  static async report(err, req, res) {
    // 1. Логируем в систему мониторинга
    if (process.env.SENTRY_DSN) {
      Sentry.captureException(err, {
        extra: {
          userId: req.user?.id,
          method: req.method,
          url: req.url,
          ip: req.ip,
          correlationId: req.correlationId,
          userAgent: req.headers['user-agent'],
        },
      });
    }

    // 2. Отправляем в Slack/Telegram для быстрых оповещений
    if (process.env.SLACK_WEBHOOK) {
      await this.sendToSlack(err, req);
    }

    // 3. Отправляем email разработчикам (если ошибка серьезная)
    if (err.isOperational === false) {
      await this.sendEmailAlert(err, req);
    }

    // 4. Пользователю - человеческий ответ
    const isDev = process.env.NODE_ENV === 'development';
    
    res.status(err.statusCode || 500).json({
      success: false,
      error: {
        code: err.code || 'INTERNAL_ERROR',
        message: err.isOperational ? err.message : 'Внутренняя ошибка сервера',
        ...(isDev && { stack: err.stack }),
      },
      support: {
        id: req.correlationId,
        email: 'support@yourapp.com',
        url: 'https://status.yourapp.com',
      },
      timestamp: new Date().toISOString(),
    });
  }

  static async sendToSlack(err, req) {
    const slackMessage = {
      text: `🚨 *Ошибка в продакшене!*`,
      attachments: [
        {
          color: err.isOperational ? 'warning' : 'danger',
          fields: [
            { title: 'Тип ошибки', value: err.isOperational ? 'Операционная' : 'Программистская', short: true },
            { title: 'Сообщение', value: err.message, short: false },
            { title: 'Endpoint', value: `${req.method} ${req.url}`, short: true },
            { title: 'User ID', value: req.user?.id || 'anonymous', short: true },
            { title: 'IP', value: req.ip, short: true },
            { title: 'Correlation ID', value: req.correlationId, short: false },
            { title: 'Stack', value: `\`\`\`${err.stack?.slice(0, 500)}\`\`\``, short: false },
            { title: 'Timestamp', value: new Date().toISOString(), short: true },
          ],
        },
      ],
    };
    
    await axios.post(process.env.SLACK_WEBHOOK, slackMessage);
  }

  static async sendEmailAlert(err, req) {
    // Отправка email с подробным отчетом
    // Используйте nodemailer, SendGrid, или AWS SES
  }
}

// Использование в централизованном обработчике
app.use((err, req, res, next) => {
  ErrorReporter.report(err, req, res);
});

Полный чек-лист для продакшена (25/25)

Пройдите по списку перед деплоем:

Фундамент:

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

  • Создана иерархия классов ошибок с error.cause

  • Настроен централизованный обработчик ошибок

  • Все catch либо логируют, либо пробрасывают

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

Асинхронность:

  • Все промисы имеют .catch()

  • Настроены uncaughtException и unhandledRejection

  • Используется Promise.allSettled где нужно

  • Настроены таймауты для промисов

Логгирование и мониторинг:

  • Используется структурированное логгирование (pino/winston)

  • Все ошибки логируются с контекстом

  • Настроен correlation ID

  • Настроен APM (Sentry/NewRelic/Datadog)

  • Логи санитайзятся от конфиденциальных данных

Безопасность:

  • Валидация входных данных (Joi/Zod)

  • Настроен rate limiting

  • Используется helmet

  • Обрезаются стеки в продакшене

Продакшен-готовность:

  • Настроены retry-механизмы

  • Настроен Circuit Breaker

  • Есть health-чеки /health, /live, /ready)

  • Настроен graceful shutdown

  • Учтены GEO-особенности (таймауты для РФ)

  • Настроена система репортинга ошибок

Отладка:

  • Добавлен diagnostics_channel для разработки

  • Используются correlation ID в логах

  • Есть интеграция с Sentry/NewRelic

Часто задаваемые вопросы (FAQ)

1. Почему сервер падает из-за unhandledRejection?

В Node.js это происходит, когда промис отклонён, но нет .catch(). В новых версиях Node.js это приведёт к крашу.

2. Как отличить операционную ошибку от программистской?

  • Операционная: ошибка, которую можно предсказать и обработать (файл не найден, БД упала)

  • Программистская: ошибка в коде (неверный доступ к свойству, неправильный тип)

3. Нужен ли process.exit(1) в uncaughtException?

Да. После uncaughtException сервер может быть в нестабильном состоянии. Лучше перезапустить через pm2 или kubernetes.

4. Что делать с ошибками в продакшене?

  • Логировать в структурированном формате

  • Отправлять в APM (Sentry/NewRelic)

  • Настроить алерты в Slack/Telegram

  • Показывать пользователю понятное сообщение

5. Как тестировать обработку ошибок?

// Юнит-тест
test('should handle database error', async () => {
  const mockDb = {
    query: jest.fn().mockRejectedValue(new Error('DB down')),
  };
  
  await expect(getUser(mockDb, 1)).rejects.toThrow(DatabaseError);
});

// Интеграционный тест
test('should return 503 when external API fails', async () => {
  nock('https://api.example.com')
    .get('/user/1')
    .reply(500);
  
  const response = await request(app).get('/user/1');
  expect(response.status).toBe(503);
});

Итоги части 3

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

  1. Настройте graceful shutdown - корректно завершайте работу

  2. Используйте retry-механизмы для внешних сервисов

  3. Добавьте Circuit Breaker для защиты от каскадных отказов

  4. Настройте health-чеки для мониторинга

  5. Настройте репортинг ошибок в Slack/Telegram

  6. Тестируйте обработку ошибок - юнит и интеграционные тесты

Общие итоги: 25 практик в одном чек-листе

Обработка ошибок в Node.js - это не просто try/catch. Это целая архитектура, которая начинается с правильной классификации ошибок и заканчивается мониторингом в продакшене.

Ключевые выводы всей статьи:

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

  2. Используйте error.cause (Node.js 16+) - чтобы не терять оригинальную ошибку

  3. Настройте uncaughtException и unhandledRejection - это ваш последний рубеж

  4. Внедрите структурированное логгирование - JSON-логи спасут при поиске проблем

  5. Добавьте APM и корреляцию запросов - чтобы видеть всю цепочку ошибки

  6. Используйте retry и Circuit Breaker - чтобы защититься от каскадных отказов

  7. Настройте graceful shutdown - чтобы завершать работу чисто

  8. Добавьте health-чеки - чтобы мониторинг видел статус сервиса

Используйте этот чек-лист перед каждым деплоем - и ваш сервер будет работать стабильно, а вы будете спать спокойно

Комментарии

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

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

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