Node.js Internals
Error Handling: Обработка ошибок
Представьте: ваш production сервис обрабатывает миллион запросов в день. Один невалидный JSON от пользователя - и весь сервер падает, потому что вы забыли `try/catch`. Или деплой новой версии - и 10,000 активных WebSocket соединений обрываются, потому что не было graceful shutdown. Или внешний API недоступен 5 минут - и ваш сервис висит, потому что нет timeout и circuit breaker.
- **GitHub outage 2018:** Сбой БД привёл к каскадному падению всех сервисов. Не было circuit breaker — каждый сервис пытался подключиться к мёртвой БД, тратил все connection pools, падал. Решение: circuit breaker + fallback на read-only режим.
- **Slack incident 2020:** При деплое не было graceful shutdown. Активные WebSocket соединения обрывались, пользователи теряли сообщения. 100,000+ пользователей отключились одновременно. Решение: graceful shutdown с drain периодом 30s + health check.
- **AWS Lambda cold start:** Если не обрабатывать unhandledRejection, функция падает без логов. Дебажить невозможно — нет stack trace, нет метрик. Решение: глобальные обработчики + structured logging + graceful error boundaries.
Типы ошибок и Error класс
Представьте: вы пишете сервер на Node.js. Пользователь отправил невалидный JSON - это **operational error** (ожидаемая ошибка). Вы забыли проверить `null` перед `.toString()` - это **programmer error** (баг). Первую нужно обработать и вернуть 400, вторую - залогировать и исправить код.
**Operational errors** - это ожидаемые проблемы, часть нормальной работы приложения: сеть недоступна, файл не найден, база данных отклонила запрос, пользователь ввёл неверные данные. Их **нужно** обрабатывать. **Programmer errors** - это баги в коде: обращение к undefined свойству, передача неверного типа аргумента, бесконечная рекурсия. Их **нельзя** обработать - нужно исправлять код.
**Золотое правило:** Operational errors обрабатываем, programmer errors логируем и крашим процесс. Не пытайтесь восстановиться после программной ошибки - состояние приложения может быть непредсказуемым.
**Error класс** в Node.js содержит: - `message` - описание ошибки - `stack` - stack trace (где произошла ошибка) - `name` - тип ошибки (Error, TypeError, RangeError, ...) - Дополнительные поля для конкретных ошибок: `code` (ENOENT, ECONNREFUSED), `syscall`, `errno`
Stack trace - карта преступления
Stack trace показывает путь выполнения кода до ошибки: ``` Error: User not found at UserService.getUser (/app/services/user.service.ts:45:11) at UserController.getProfile (/app/controllers/user.controller.ts:23:28) at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5) ``` **Читаем снизу вверх:** 1. Express вызвал ваш контроллер 2. Контроллер вызвал UserService.getProfile 3. В строке 45 user.service.ts выбросилась ошибка **Важно:** В production минимизируйте stack trace (убирайте node_modules), но сохраняйте исходные карты для дебага.
**Антипаттерн:** `catch (err) { console.log(err) }` - это НЕ обработка ошибки! Вы залогировали и продолжили работу, как будто ничего не произошло. В результате пользователь получает успешный ответ с `undefined` данными, а через 5 минут приложение крашится с загадочной ошибкой.
Ваш API эндпоинт получает JSON, парсит его и сохраняет в БД. При парсинге выбросился SyntaxError. Что это за тип ошибки и как её обрабатывать?
Асинхронные ошибки
В синхронном коде `try/catch` работает идеально. Но в асинхронном мире Node.js ошибки могут "потеряться" и уронить процесс. Разберём эволюцию от callbacks до async/await и узнаем, где подстелить соломку.
**unhandledRejection** - самая опасная ошибка в Node.js. До версии 15 процесс НЕ падал, а просто логировал. С версии 15+ процесс **крашится**. Всегда обрабатывайте rejected promises!
Реальный кейс: микросервис упал без логов
**Проблема:** Сервис обрабатывает очередь RabbitMQ. Раз в час падает без логов. **Расследование:** ```typescript // Код выглядел нормально async function processMessage(msg) { const data = JSON.parse(msg.content); await saveToDatabase(data); channel.ack(msg); } channel.consume(queue, processMessage); ``` **Проблема:** `processMessage` не ловит ошибки. Если `saveToDatabase` выбросит ошибку, она превратится в unhandledRejection. **Решение:** ```typescript async function processMessage(msg) { try { const data = JSON.parse(msg.content); await saveToDatabase(data); channel.ack(msg); } catch (err) { console.error('Failed to process message:', err); channel.nack(msg); // Вернём в очередь } } ``` Теперь ошибки обрабатываются, сервис не падает, сообщения возвращаются в очередь.
**Async wrapper для Express:** Стандартный Express не ловит ошибки из async middleware. Нужен wrapper: ```typescript const asyncHandler = (fn) => (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; app.get('/user/:id', asyncHandler(async (req, res) => { const user = await db.getUser(req.params.id); res.json(user); // Ошибки автоматически попадут в error handler })); ``` Или используйте express-async-errors пакет.
У вас Express API с async/await хендлерами. Один из эндпоинтов выбросил ошибку, но не было try/catch. Что произойдёт?
AsyncLocalStorage и Error Boundaries
**Domains API** (deprecated с Node.j 4) пытались решить проблему: как изолировать ошибки разных запросов? Если один запрос крашнулся, не должен упасть весь сервер. Но domains оказались сложными и ненадёжными. Их заменили на **AsyncLocalStorage** для контекста и **error boundaries** для изоляции.
**AsyncLocalStorage** (Node.js 12.17+) - это способ пробросить контекст через всю цепочку async вызовов без явной передачи параметров. Идеально для request ID, user ID, трейсинга.
**AsyncLocalStorage vs global переменные:** Глобальные переменные общие для всех запросов (race conditions). AsyncLocalStorage изолирует контекст каждого async flow - даже если 1000 запросов выполняются одновременно, каждый видит свой requestId.
Request ID tracing в микросервисах
**Проблема:** У вас 5 микросервисов. Ошибка происходит где-то в цепочке, но непонятно в каком запросе. **Решение:** Пробрасываем request ID через все сервисы: ```typescript // API Gateway создаёт requestId app.use((req, res, next) => { const requestId = req.headers['x-request-id'] || uuidv4(); requestContext.run({ requestId }, () => next()); }); // При вызове другого сервиса async function callUserService(userId: string) { const ctx = requestContext.getStore(); const response = await fetch(`http://user-service/users/${userId}`, { headers: { 'X-Request-ID': ctx?.requestId // Пробрасываем дальше } }); return response.json(); } // В логах всех сервисов один requestId // Можно проследить весь путь запроса: // API Gateway [req-123] -> User Service [req-123] -> DB error [req-123] ```
**Performance overhead:** AsyncLocalStorage имеет небольшой overhead (~5-10% на async операции). В большинстве случаев это незаметно, но если у вас миллионы RPS и tight latency budget - измерьте перед внедрением.
У вас Express API. Нужно логировать requestId в каждой ошибке. Как лучше это сделать?
Graceful Shutdown
Вы деплоите новую версию сервиса. Kubernetes отправляет **SIGTERM**, даёт 30 секунд на завершение и убивает процесс. Если вы просто вызовете `process.exit()`, текущие запросы оборвутся, транзакции откатятся, клиенты получат 502. **Graceful shutdown** - это корректное завершение: дождаться текущих запросов, закрыть соединения, сохранить состояние.
**SIGTERM vs SIGKILL:** SIGTERM - вежливая просьба завершиться (можно обработать). SIGKILL - немедленное убийство процесса (обработать нельзя). Kubernetes сначала шлёт SIGTERM, ждёт terminationGracePeriodSeconds (default 30s), затем SIGKILL.
Реальный кейс: rolling deployment без downtime
**Проблема:** При деплое Kubernetes убивает старые поды. Если они обрабатывают запросы, клиенты получают 502. **Решение:** 1. **preStop hook** в Kubernetes - даёт время перед SIGTERM: ```yaml lifecycle: preStop: exec: command: ["/bin/sh", "-c", "sleep 5"] ``` Это даёт время Ingress/Service обновить endpoints. 2. **Graceful shutdown** в Node.js - дожидаемся запросов 3. **Health check** отдаёт 503 при shutdown - load balancer перестаёт слать трафик 4. **Timeout** - если запросы не завершились за 30s, форсируем shutdown Результат: 0 потерянных запросов при деплое.
**Keep-Alive connections:** HTTP keep-alive держит соединение открытым. `server.close()` НЕ убивает активные keep-alive соединения - они могут висеть минутами. Используйте библиотеку `http-terminator` или вручную трекайте соединения и вызывайте `socket.destroy()`.
Ваш Node.js сервис обрабатывает долгие запросы (до 60 секунд). При деплое в Kubernetes запросы обрываются. terminationGracePeriodSeconds = 30s. Как исправить?
Recovery Patterns
Ошибки неизбежны: сеть упала, база перегружена, внешний API недоступен. **Recovery patterns** - это стратегии восстановления после сбоев. Вместо того чтобы сразу крашиться, сервис пытается восстановиться: повторяет запрос, переключается на fallback, изолирует сломанный компонент.
**Основные паттерны восстановления:** **1. Retry** - повторяем операцию через delay. Используйте для временных сбоев (сеть, перегрузка). **2. Circuit Breaker** - если сервис ломается, перестаём его дёргать ("размыкаем цепь"). Через timeout пробуем снова. **3. Fallback** - если основной путь не работает, используем запасной (кеш, дефолтные данные, упрощённую логику). **4. Timeout** - ограничиваем время ожидания. Лучше вернуть ошибку быстро, чем висеть минутами. **5. Health Check** - периодически проверяем состояние зависимостей. Если БД недоступна, health check отдаёт 503.
Реальный кейс: каскадный сбой микросервисов
**Проблема:** Сервис A вызывает сервис B, который вызывает C. Сервис C упал. Теперь: - C не отвечает (timeout 30s) - B висит в ожидании C, накапливаются запросы - A висит в ожидании B - Все сервисы перегружены, падают по цепочке **Решение:** 1. **Timeout:** Ограничить время ожидания (5s вместо 30s) 2. **Circuit Breaker:** После 5 ошибок C, сервис B перестаёт его дёргать 3. **Fallback:** B возвращает кешированные данные вместо свежих 4. **Health Check:** B отдаёт 503 если C недоступен, A переключается на другой инстанс B Результат: Сбой C не роняет всю систему.
**Библиотеки для recovery patterns:** - `cockatiel` - circuit breaker, retry, timeout, fallback - `p-retry` - простой retry с backoff - `async-retry` - retry для async/await - `opossum` - circuit breaker для Node.js - `nestjs-resilience` - интеграция с NestJS
Retry решает все проблемы - если запрос упал, просто повторю его
Retry подходит только для временных сбоев (сеть дёрнулась). Если сервис лежит, retry усугубит проблему - добавит нагрузки на умирающий сервис. Нужны circuit breaker + fallback + timeout
**Thundering herd problem:** Все клиенты одновременно retry'ят упавший сервис → он получает 10x нагрузки и не может восстановиться. Circuit breaker изолирует сломанный компонент, даёт ему время восстановиться. Fallback возвращает хоть какой-то ответ клиенту вместо бесконечного ожидания.
Ваш сервис вызывает внешний API, который иногда падает на 5-10 минут. Клиенты жалуются на таймауты. Какая стратегия лучше?
Ключевые идеи
- **Operational vs Programmer errors:** Operational (сеть, файлы, ввод) обрабатываем, programmer (баги) логируем и крашим. Не пытайтесь восстановиться после багов — состояние непредсказуемо.
- **Async errors убивают процесс:** Забыли `.catch()` или `try/await` → unhandledRejection → краш (Node.js 15+). Используйте async wrapper для Express, глобальные обработчики для логирования.
- **AsyncLocalStorage для контекста:** Храните requestId, userId, tracing без явной передачи параметров. Изолировано для каждого async flow.
- **Graceful shutdown обязателен:** SIGTERM → stop accept → drain connections → cleanup → exit. Без этого деплой = потерянные запросы и 502 ошибки.
- **Recovery patterns:** Retry (временные сбои), Circuit Breaker (изолировать сломанное), Fallback (кеш/дефолты), Timeout (не висеть вечно), Health Check (отдать 503).
Связанные темы
Error handling связан со всей экосистемой Node.js:
- Event Loop — unhandledRejection обрабатывается в отдельной фазе Event Loop. Blocking операции в error handler заморозят весь сервер.
- Async Hooks — AsyncLocalStorage построена на async_hooks - трекинг async операций для изоляции контекста.
- Cluster — Worker упал - master перезапускает. Graceful shutdown критичен для zero-downtime rolling restart.
- Production Patterns — Error handling - часть production-ready сервиса: логирование, мониторинг, alerting, SLA.
Вопросы для размышления
- Посмотрите свой код: есть ли async функции без try/catch или .catch()? Что произойдёт если они выбросят ошибку?
- Есть ли у вашего сервиса graceful shutdown? Что случится с активными запросами при деплое?
- Как ваш сервис реагирует на недоступность БД или внешнего API? Есть ли retry, circuit breaker, fallback?
- Можете ли вы проследить request ID от входа в систему до ошибки в логах? Как долго займёт расследование production incident?