open atlas
↑ К треку
Node.js с нуля до senior NODE · 05 · 03

Middleware, границы ошибок и graceful shutdown

Middleware — это упорядоченный конвейер, завершающийся одной 4-арг границей ошибок, которая мапит типизированные ошибки в статусы и безопасное тело. Async-throw в Express 4 надо прокидывать, а SIGTERM обязан слить запросы в полёте через server.close().

NODE Middle ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Каждый деплой дашборды загорались всплеском 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.bodyreq.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. 1 SIGTERM приходит от оркестратора (Kubernetes / Docker / systemd)
  2. 2 Переключи readiness-пробу в 'не готов', чтобы балансировщик слил новый трафик
  3. 3 Вызови server.close(), чтобы перестать принимать новые соединения, пока запросы в полёте завершаются
  4. 4 В колбэке server.close() закрой пул БД и другие ресурсы
  5. 5 process.exit(0) — или жёсткий setTimeout форсирует exit(1), если слив застрял
Вспомните перед уходом
  1. 01
    В Express 4 почему reject внутри async-обработчика маршрута вешает запрос вместо того, чтобы дойти до твоего обработчика ошибок, и какие два фикса?
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.