Роутинг, обработка ошибок и валидация

«Где вы обрабатываете ошибки в Express?» — вопрос, по ответу на который сразу видно, писал ли человек прод или только туториалы.

В Express 4 асинхронные ошибки не попадают в обработчик ошибок сами: throw внутри async-функции превращается в отклонённый промис, о котором фреймворк ничего не знает.

Что проверяет интервьюер

Три вещи: понимаете ли вы, что обработка ошибок должна быть централизованной; знаете ли ловушку с async-обработчиками; и относитесь ли вы к входным данным как к недоверенным. Последнее — вопрос уже про безопасность, и на senior-позициях он весит больше всего.

Роутинг: порядок и параметры

const router = require('express').Router();

router.get('/orders/new', renderForm);      // конкретный путь — раньше
router.get('/orders/:id', getOrder);        // иначе ':id' поймает 'new'

router.param('id', (req, res, next, id) => {
  if (!/^\d+$/.test(id)) return next(new BadRequestError('id должен быть числом'));
  req.orderId = Number(id);
  next();
});

app.use('/api/v1', router);                 // весь роутер под общим префиксом

Маршруты проверяются сверху вниз до первого совпадения, поэтому статические пути всегда регистрируют раньше параметрических. И маленькая деталь, которая нравится интервьюерам: req.params.id — это всегда строка, приведение типа и проверка формата на вас.

Централизованный обработчик ошибок

class AppError extends Error {
  constructor(status, code, message) {
    super(message);
    this.status = status;
    this.code = code;
  }
}

// регистрируется последним, после всех маршрутов
app.use((err, req, res, next) => {
  const status = err.status || 500;

  logger.error({ err, requestId: req.id, url: req.originalUrl }, 'request failed');

  res.status(status).json({
    error: {
      code: err.code || 'internal_error',
      // наружу отдаём текст только для «ожидаемых» ошибок
      message: status < 500 ? err.message : 'Внутренняя ошибка сервера',
    },
  });
});

Правило, которое стоит проговорить: детали 500-й ошибки наружу не отдаются никогда. Стектрейс и сообщение драйвера базы уходят в лог, клиент получает нейтральный текст и код ошибки. Иначе вы бесплатно рассказываете атакующему про структуру базы и версии библиотек.

Ловушка с async-обработчиками

// Express 4: ошибка НЕ дойдёт до errorHandler, будет unhandled rejection
app.get('/orders/:id', async (req, res) => {
  const order = await db.getOrder(req.params.id);   // если упадёт — тишина
  res.json(order);
});

// вариант 1: try/catch и next(err) вручную
app.get('/orders/:id', async (req, res, next) => {
  try {
    res.json(await db.getOrder(req.params.id));
  } catch (err) {
    next(err);
  }
});

// вариант 2: обёртка, чтобы не писать try/catch в каждом маршруте
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

app.get('/orders/:id', asyncHandler(async (req, res) => {
  res.json(await db.getOrder(req.params.id));
}));

В Express 5 эту проблему решили: отклонённый промис из обработчика автоматически уходит в next(err). Но проектов на четвёртой версии по-прежнему подавляющее большинство, поэтому знание про asyncHandler — практически обязательное.

Валидация входных данных

Валидировать нужно всё, что приходит извне: body, query, params, заголовки. Ключевой принцип — белый список: описываем разрешённые поля и типы, всё остальное отбрасываем. Иначе легко получить mass assignment, когда клиент присылает {"role": "admin"}, а код честно пишет это в базу.

const { z } = require('zod');

const createUserSchema = z.object({
  email: z.string().email(),
  age: z.number().int().min(0).max(150),
  role: z.enum(['user', 'editor']).default('user'),   // 'admin' не пройдёт
});

const validate = (schema) => (req, res, next) => {
  const result = schema.safeParse(req.body);
  if (!result.success) {
    return next(new AppError(400, 'validation_failed', result.error.message));
  }
  req.body = result.data;        // дальше идут только очищенные данные
  next();
};

app.post('/users', validate(createUserSchema), asyncHandler(createUser));

Библиотека может быть любой — zod, joi, express-validator, ajv. Важна не она, а схема ответа: валидация оформлена как middleware, ошибки идут в общий обработчик, а в бизнес-логику попадают уже проверенные и приведённые к типам данные. Про коды: 400 — тело не разобрано или структура неверна, 422 — структура верна, но значения не проходят бизнес-правила; строгого стандарта нет, важно быть последовательным.

Типичные ошибки кандидатов

  • Обрабатывают ошибки try/catch в каждом маршруте и не знают про централизованный обработчик.
  • Не знают ловушку с async в Express 4.
  • Отдают клиенту err.stack или сообщение драйвера БД.
  • Валидируют только body, забывая про query и params.
  • Пишут User.create(req.body) напрямую — прямой путь к mass assignment.

Как ответить кратко

Маршруты проверяются сверху вниз, поэтому конкретные пути регистрируют раньше параметрических. Ошибки собираются в одном middleware с четырьмя аргументами, зарегистрированном последним: он логирует детали и отдаёт клиенту безопасный ответ, никогда не показывая стектрейс на 500. В Express 4 ошибка из async-обработчика сама туда не попадёт — нужен try/catch с next(err) или обёртка asyncHandler; в Express 5 это уже работает из коробки. Входные данные валидируются схемой в отдельном middleware по принципу белого списка, чтобы в логику не проходили лишние поля.

Проверьте себя
1. Почему в Express 4 ошибка из async-обработчика не доходит до errorHandler?
AПотому что async-функции запрещены в маршрутах
BПотому что throw превращается в отклонённый промис, который фреймворк не отслеживает
CПотому что errorHandler ловит только ошибки со статусом 500
DПотому что ошибка теряется в очереди микрозадач
2. Что следует отдавать клиенту при ошибке со статусом 500?
AПолный стектрейс — так проще отлаживать
BСообщение драйвера базы данных
CНейтральное сообщение и код ошибки, а детали писать в лог
DПустое тело ответа без статуса
3. Зачем валидировать данные по принципу белого списка полей?
AЧтобы ускорить разбор JSON
BЧтобы клиент не мог подсунуть лишние поля вроде role и получить mass assignment
CЧтобы Express корректно определил Content-Type
DЭто требование спецификации HTTP