Middleware, границы ошибок и graceful shutdown
Middleware — это упорядоченный конвейер, завершающийся одной 4-арг границей ошибок, которая мапит типизированные ошибки в статусы и безопасное тело. Async-throw в Express 4 надо прокидывать, а SIGTERM обязан слить запросы в полёте через server.close().
Каждый деплой дашборды загорались всплеском 502 — несколько сотен упавших запросов, потом тишина. Само приложение было здорово; раскатка просто убивала поды посреди запроса. Kubernetes слал SIGTERM, старый процесс умирал мгновенно, и каждый запрос в полёте — включая 9-секундный экспорт отчёта — обрывался на сокете, всплывая к балансировщику как 502. Фиксом был не кластер побольше и не библиотека ретраев. Это были восемь строк: поймать SIGTERM, вызвать server.close(), чтобы перестать принимать новые соединения, дав открытым завершиться, и затем выйти. Авария была не про ёмкость. Она была про процесс, который не умел прощаться.
Этот урок покрывает три дисциплины, которые живут в одной кодовой базе и ломаются вместе: порядок middleware, граница ошибок и graceful shutdown (корректное завершение) — ошибись в любой одной, и пользователи получат тихие баги или всплески 502.
Middleware — это упорядоченный конвейер
Почему важно, что middleware бежит в определённом порядке? Один неправильно поставленный app.use() может молча убрать авторизацию с маршрута или сделать req.body равным undefined в проде — без единой ошибки. HTTP-фреймворк вроде Express в своей сути — это массив функций, вызываемых в том порядке, в каком ты их зарегистрировал. Каждая (req, res, next) выполняется, делает свою долю работы и зовёт next(), передавая управление следующей, — или зовёт next(err), чтобы пропустить всё и прыгнуть прямо к обработчику ошибок. Запрос течёт через массив; ответ течёт назад. Поскольку это упорядоченный массив, порядок регистрации — это поведение, а не стиль: парсер тела, зарегистрированный после маршрута, означает, что маршрут видит req.body как undefined; проверка авторизации после защищённого маршрута означает, что маршрут отработает для неаутентифицированных пользователей.
import express from "express";
const app = express();
app.use(logger); // 1. сквозное: бежит для каждого запроса
app.use(express.json()); // 2. парсер тела — ДОЛЖЕН быть до маршрутов, читающих req.body
app.use(authenticate); // 3. наполняет req.user (или next(err) при плохом токене)
app.get("/orders/:id", requireAuth, getOrder); // 4. маршруты, после их предпосылок
app.use(notFound); // 5. 404 — достигается, только если ни один маршрут не совпал
app.use(errorHandler); // 6. граница ошибок — 4 аргумента, регистрируется ПОСЛЕДНЕЙМентальная модель — воронка: сквозные заботы первыми (логирование, request id, CORS), затем парсинг, затем аутентификация и авторизация, затем валидация, затем маршрут. Два терминальных обработчика — перехватчик 404 и граница ошибок — идут после всех маршрутов, потому что middleware после res.send() (или после того как совпавший маршрут завершил ответ) никогда не бежит. Композиция — это выигрыш: логирование, авторизация, rate-limiting и валидация живут каждый в одной переиспользуемой функции вместо копипаста в каждый обработчик.
Одна централизованная граница ошибок
Express распознаёт одну особую форму: middleware с четырьмя аргументами, (err, req, res, next). Арность — это сигнал: Express считает fn.length === 4, чтобы классифицировать её как обработчик ошибок, поэтому случайно выброшенный next превращает твой обработчик ошибок в молча пропускаемый обычный middleware. Тебе нужна ровно одна такая, зарегистрированная последней, и она делает три работы: мапит типизированную ошибку в код статуса, шлёт безопасное тело клиенту и логирует полную ошибку для операторов.
function errorHandler(err, req, res, next) {
if (res.headersSent) return next(err); // ответ начат — пусть Express его закроет
const status = err.status ?? statusFor(err); // ValidationError→400, NotFoundError→404
logger.error({ err, reqId: req.id }, "request failed"); // полный стек + cause для ops
res.status(status).json({
error: { code: err.code ?? "INTERNAL", message: status < 500 ? err.message : "Internal error" },
}); // 5xx прячет внутренности; 4xx может отдать сообщение валидации
}Ключевое разделение — взгляд клиента vs взгляд оператора. Клиент получает стабильную форму — код и сообщение, — и для 5xx это сообщение — общее "Internal error", никогда не трасса стека, фрагмент SQL или путь к файлу. Утечка стека клиенту вручает атакующему версии твоих зависимостей и внутреннюю структуру. Операторы получают обратное: весь объект Error с его stack и цепочкой cause в логах. Одно место решает маппинг, так что новый тип ошибки — это одна правка, а не grep по всем обработчикам.
| Middleware | Должен идти до | Сбой при неверном порядке |
|---|---|---|
Парсер тела (express.json()) | любого маршрута, читающего req.body | req.body равен undefined |
Авторизация / requireAuth | защищённых маршрутов | обработчик бежит для анонимов |
| Перехватчик 404 | обработчика ошибок, после всех маршрутов | не достигается или заслоняет маршруты |
| Обработчик ошибок (4-арг) | ничего — он ПОСЛЕДНИЙ | если первый — не бежит вообще |
Почему middleware обработки ошибок должен регистрироваться ПОСЛЕДНИМ, после всех маршрутов и обработчика 404, и почему ему нужны четыре аргумента?
Async-ошибки и валидация на границе
Вот ловушка Express 4, порождающая тихие, зависшие запросы. Синхронный throw внутри обработчика ловится Express и направляется к обработчику ошибок. Но reject внутри async-обработчика — нет: Express 4 никогда не await-ит твой обработчик, так что зареджекченный промис ускользает в process как unhandledRejection, запрос клиента висит до таймаута, а твоя граница ошибок его никогда не видит.
// Express 4: этот reject НЕ ловится — запрос висит, срабатывает unhandledRejection
app.get("/users/:id", async (req, res) => {
const user = await db.findUser(req.params.id); // reject? ускользает из Express
res.json(user);
});
// Фикс A — try/catch и явный проброс
app.get("/users/:id", async (req, res, next) => {
try { res.json(await db.findUser(req.params.id)); }
catch (err) { next(err); } // теперь доходит до границы ошибок
});
// Фикс B — обёртка (или пакет express-async-errors) делает это для каждого маршрута
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
app.get("/users/:id", wrap(async (req, res) => res.json(await db.findUser(req.params.id))));Express 5 чинит это на уровне фреймворка: зареджекченный промис, возвращённый из обработчика, автоматически прокидывается в next(err), так что обёртка становится не нужна. Пока ты не на 5, оборачивай каждый async-обработчик. Спутник этого паттерна — middleware валидации (zod, joi, celebrate), который отвергает кривой ввод на границе с 400 до того, как отработает логика любого обработчика, — так что твой бизнес-код может считать, что req.body уже правильной формы, а плохой ввод становится чистой типизированной ValidationError вместо глубокого TypeError.
▸Почему это работает
Почему reject «вешает» запрос вместо того, чтобы просто уронить? Когда async-обработчик реджектит, никакой код никогда не зовёт res.send, res.json или next для этого запроса. У Express нет собственного таймаута, так что сокет остаётся открытым, и клиент ждёт. Процесс продолжает обслуживать другие запросы; застряло только это одно соединение, пока клиент или прокси не сдадутся (часто 30–60с). Поэтому это хуже падения — это невидимо в метриках успеха и проявляется лишь как выбросы латентности и unhandledRejection в логах.
На Express 4 async-обработчик маршрута делает `await db.query(...)`, который реджектит, без try/catch и без обёртки. Что происходит?
Graceful shutdown по SIGTERM
Оркестраторы останавливают процесс, посылая SIGTERM (так делают Kubernetes, Docker stop и systemd). Поведение Node по умолчанию для SIGTERM — выйти немедленно, обронив каждый запрос в полёте. Graceful shutdown означает: перестать принимать новые соединения, дать существующим запросам завершиться, освободить ресурсы, затем выйти.
process.on("SIGTERM", async () => {
isReady = false; // 1. переключи readiness, чтобы LB перестал слать новый трафик
server.close(async () => { // 2. стоп новым conn; колбэк бежит, когда полёт завершён
await pool.end(); // 3. закрой пул БД и другие ресурсы
process.exit(0);
});
setTimeout(() => process.exit(1), 10_000).unref(); // 4. жёсткий потолок — не висни вечно
});Важны четыре момента. Первое, переключи readiness-пробу в «не готов» до server.close(), чтобы балансировщик слил тебя вместо отправки запросов на порт, который вот-вот закроется. Второе, server.close() останавливает новые соединения и зовёт свой колбэк только когда существующие запросы завершились. Третье, жёсткий setTimeout(..., 10_000) обязателен: HTTP keep-alive оставляет простаивающие соединения открытыми, и простаивающий keep-alive сокет считается «соединением», которое может не дать server.close() сработать никогда, — так что ты форсируешь выход по дедлайну. Четвёртое, числа: дефолтный terminationGracePeriodSeconds Kubernetes — 30с, после чего он шлёт SIGKILL и ты теряешь контроль, — так что твой жёсткий таймаут (например, 10с) должен быть с запасом внутри этого окна.
По SIGTERM в поде Kubernetes как HTTP-сервис должен завершаться, чтобы раскатка дала ноль обронённых запросов?
Расставь последовательность graceful shutdown после прихода SIGTERM, чтобы раскатка обронила ноль запросов:
- 1 SIGTERM приходит от оркестратора (Kubernetes / Docker / systemd)
- 2 Переключи readiness-пробу в 'не готов', чтобы балансировщик слил новый трафик
- 3 Вызови server.close(), чтобы перестать принимать новые соединения, пока запросы в полёте завершаются
- 4 В колбэке server.close() закрой пул БД и другие ресурсы
- 5 process.exit(0) — или жёсткий setTimeout форсирует exit(1), если слив застрял
- 01В Express 4 почему reject внутри async-обработчика маршрута вешает запрос вместо того, чтобы дойти до твоего обработчика ошибок, и какие два фикса?
- 02Пройди корректный graceful shutdown по SIGTERM и объясни, почему жёсткий таймаут и переключение readiness оба обязательны.
HTTP-фреймворк — это упорядоченный конвейер функций (req, res, next): каждая зовёт next(), чтобы продвинуться, или next(err), чтобы прыгнуть к обработчику ошибок, так что порядок регистрации — это поведение: парсер тела должен предшествовать маршрутам, читающим req.body, авторизация — защищённым маршрутам, а перехватчик 404 и граница ошибок должны идти после всех маршрутов. Эта граница — один middleware ровно с четырьмя аргументами, (err, req, res, next) — Express классифицирует её по арности, — и она делает три вещи: мапит типизированную ошибку в код статуса (ValidationError→400, NotFoundError→404, иначе 500), шлёт безопасное тело клиенту, прячущее стеки и внутренности на 5xx, и логирует полную ошибку с её цепочкой cause для операторов. Острая ловушка Express 4 в том, что reject внутри async-обработчика никогда не await-ится, так что он ускользает в unhandledRejection, вешает запрос и не доходит до твоей границы — чини через try/catch + next(err) или обёртку, тогда как Express 5 прокидывает зареджекченные промисы автоматически, а middleware валидации отвергает плохой ввод с 400 на краю. Наконец, graceful shutdown: по SIGTERM переключи readiness в не-готов, чтобы балансировщик слил тебя, вызови server.close(), чтобы завершить запросы в полёте, закрой пул БД и выйди — с жёстким setTimeout-форс-выходом, потому что простаивающие keep-alive сокеты иначе могут держать сервер открытым дольше 30с terminationGracePeriodSeconds Kubernetes и его неперехватываемого SIGKILL. Теперь, когда деплой даёт всплеск 502, ты сразу знаешь, куда смотреть: SIGTERM-обработчик, переключение readiness и жёсткий таймаут.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.