Часть 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
Ключевые выводы:
Настройте graceful shutdown - корректно завершайте работу
Используйте retry-механизмы для внешних сервисов
Добавьте Circuit Breaker для защиты от каскадных отказов
Настройте health-чеки для мониторинга
Настройте репортинг ошибок в Slack/Telegram
Тестируйте обработку ошибок - юнит и интеграционные тесты
Общие итоги: 25 практик в одном чек-листе
Обработка ошибок в Node.js - это не просто try/catch. Это целая архитектура, которая начинается с правильной классификации ошибок и заканчивается мониторингом в продакшене.
Ключевые выводы всей статьи:
Разделяйте операционные и программистские ошибки - это спасёт вас от ложных срабатываний алертов
Используйте
error.cause(Node.js 16+) - чтобы не терять оригинальную ошибкуНастройте
uncaughtExceptionиunhandledRejection- это ваш последний рубежВнедрите структурированное логгирование - JSON-логи спасут при поиске проблем
Добавьте APM и корреляцию запросов - чтобы видеть всю цепочку ошибки
Используйте retry и Circuit Breaker - чтобы защититься от каскадных отказов
Настройте graceful shutdown - чтобы завершать работу чисто
Добавьте health-чеки - чтобы мониторинг видел статус сервиса
Используйте этот чек-лист перед каждым деплоем - и ваш сервер будет работать стабильно, а вы будете спать спокойно
Комментарии
Чтобы оставить комментарий, войдите в аккаунт.