Разбор 05

Исправление ошибок API: 401, 403, 500, 404

REST API клиенты получали россыпь статус-кодов: неавторизованный доступ, запреты, падения сервера и пропавшие роуты. Разобрал каждый код отдельно.

REST API Node.js Express JWT CORS middleware HTTP-статусы
Клиент
SaaS-платформа, медианный трафик ~8 тыс. запросов/мин
Стек
Node.js, Express, jsonwebtoken, PostgreSQL, Sequelize, cors
Срок
3 дня
Итог
Четыре класса ошибок закрыты, статусы предсказуемы и документированы

Суть проблемы

Бэкенд на Express с JWT-авторизацией. Фронтенд стабильно жаловался: «то 401, то 403, то 500, то 404» — без какой-либо системы. Логи Sentry выглядели как белый шум: половина 500-х не имела тела ответа, часть 401 приходила на запросы с валидным токеном, а существующие роуты периодически отдавали 404. Ниже — конкретные симптомы, которые воспроизвелись на проде.

Ответы API До

Мешанина кодов: 401 на валидных токенах, 403 без объяснения, 500 без тела, 404 на живых роутах. Фронтенд не мог отличить «протух токен» от «нет прав» от «сервер упал».

Ответы API После

Каждый статус несёт точную семантику: 401 — проблема аутентификации с понятным сообщением, 403 — недостаток прав, 500 — логируемая ошибка с correlation id, 404 — только на несуществующих ресурсах.

Как диагностировал

Разбирал каждый код отдельно — причины у них разные, и лечение симптомов единым «улучшшим middleware» только маскировало бы корневые проблемы. Ниже сводная таблица, затем пошаговая логика диагноза.

Код
Что значит
Типичная причина
Быстрый фикс
401
Не пройдена аутентификация — сервер не знает, кто вы
Неверный/протухший JWT, отсутствие заголовка, кривой парсинг Bearer
Различать «нет токена» и «протух», ловить jwt.verify, единый формат заголовка
403
Аутентификация пройдена, но прав на действие нет
Нет проверки ownership/ролей, CORS без credentials, путаница role vs roles[]
Проверка владельца ресурса + ролей, корректный CORS с credentials: true
500
Внутренняя ошибка сервера — падение кода
Нет try/catch в async-обработчиках, нет глобального error-handler
asyncHandler-обёртка + глобальный error-handler middleware
404
Ресурс/роут не найден
Catch-all стоит до бизнес-роутов, неверный метод, trailing slash
Правильный порядок роутов, явные методы, роутер до catch-all

Пошаговый диагноз

  1. Снял логи и сгруппировал по статусамПодключился к прод-логам через kubectl logs, отфильтровал по res.statusCode. Разделил поток на четыре корзины — каждая корзина имела свой «отпечаток»: 401 концентрировался на запросах с одним из двух baseURL, 500 — на async-эндпоинтах, 404 — на методах DELETE и PUT.
  2. Вскрыл auth middlewareВ auth.middleware.js вызов jwt.verify стоял без try/catch. Если токен протух — выбрасывался TokenExpiredError, который улетал выше и превращался в 500. Отдельно: парсинг заголовка делался через split(' ')[1], который падал на лишнем пробеле "Bearer eyJ..." и тоже давал 500. Различения «нет токена вообще» и «токен невалиден» не было.
  3. Проверил отправку токена на фронтеОказалось, что один из baseURL (/api/v1) шёл через axios-инстанс с интерцептором, добавляющим Authorization, а второй (/api/v2) — через fetch без заголовка. Поэтому часть запросов получала 401 по законной причине, но это маскировало остальные баги.
  4. Посмотрел проверку правПроверки ownership не было вовсе: DELETE /api/posts/:id удалял любой авторизованный пользователь любой пост. Роли проверялись через req.user.role === 'admin', но в токене поле называлось roles и было массивом — сравнение всегда давало false, и админ получал 403 на админских эндпоинтах.
  5. Проанализировал CORS-preflightapp.use(cors()) вызывался без опций. На запросах с credentials: 'include' браузер слал preflight OPTIONS, но Access-Control-Allow-Credentials не выставлялся — браузер блокировал ответ, а в логах это выглядело как 403 от сервера.
  6. Нашёл источник 500 без телаAsync-обработчики вида async (req, res) => { ... } бросали reject, который Express 4.x не ловит автоматически. Глобального error-handler middleware не было вовсе. В одном месте обращались к req.user.id в маршруте без auth middleware — Cannot read property 'id' of undefined уходил в 500-пустышку.
  7. Разобрал порядок роутовВ app.js catch-all app.use('*', notFoundHandler) стоял до подключения бизнес-роутеров через app.use('/api', routes). Любой запрос, не совпавший с ранее зарегистрированными, уходил в 404, даже если роут был объявлен — просто ниже по файлу. Плюс часть роутов регистрировалась через router.get, а клиент слал POST.

Что исправил

Каждый код лечился отдельным коммитом, чтобы можно было откатить точечно. Ниже — пары «было/стало» по всем четырём классам ошибок.

401 — корректный auth middleware

Главная проблема: jwt.verify не ловился, протухший токен нельзя было отличить от отсутствующего, а парсинг падал на лишних пробелах. Переписал middleware с явной обработкой трёх случаев.

auth.middleware.jsбыло
// протухший токен выбрасывает TokenExpiredError
// и улетает в 500 без try/catch
function auth(req, res, next) {
  const token = req.headers.authorization.split(' ')[1]; // падает на "Bearer  eyJ..."
  const payload = jwt.verify(token, SECRET); // выброс при expiry
  req.user = payload;
  next();
}

// единый формат ошибки отсутствовал
function notFound(req, res) {
  res.status(404).send('Not found');
}
auth.middleware.jsстало
function auth(req, res, next) {
  const header = req.headers.authorization || '';

  // 1. Токена нет совсем — 401 с понятным сообщением
  if (!header.startsWith('Bearer ')) {
    return res.status(401).json({
      error: 'unauthorized',
      message: 'Отсутствует заголовок Authorization: Bearer <token>',
    });
  }

  // trim защищает от "Bearer  eyJ..." (двойной пробел)
  const token = header.slice(7).trim();

  try {
    req.user = jwt.verify(token, SECRET);
    next();
  } catch (err) {
    // 2. Различаем протухший и просто невалидный
    const code = err.name === 'TokenExpiredError'
      ? 'token_expired'
      : 'token_invalid';
    return res.status(401).json({
      error: 'unauthorized',
      code,
      message: err.message,
    });
  }
}

На фронте выровнял оба baseURL на единый axios-инстанс с интерцептором, гарантированно добавляющим заголовок. Это убрало «законные» 401 и обнажило реальные баги прав и роутов.

403 — проверка прав и CORS с credentials

Удалять чужой ресурс мог любой залогиненный. Проверка ролей сравнивала строку с массивом. CORS не отдавал Allow-Credentials. Починил всё разом.

posts.routes.jsбыло
// ownership НЕ проверяется — любой юзер удаляет любой пост
router.delete('/:id', auth, async (req, res) => {
  await Post.destroy({ where: { id: req.params.id } });
  res.status(204).end();
});

// роли: сравниваем строку с массивом — всегда false
function adminOnly(req, res, next) {
  if (req.user.role === 'admin') next(); // req.user.roles === ['admin']
  else res.status(403).send('Forbidden');
}
posts.routes.jsстало
// проверяем владельца ресурса — 403 если не хозяин и не админ
router.delete('/:id', auth, async (req, res, next) => {
  const post = await Post.findByPk(req.params.id);
  if (!post) return res.status(404).json({ error: 'not_found' });

  const isOwner = post.userId === req.user.id;
  const isAdmin = req.user.roles?.includes('admin');
  if (!isOwner && !isAdmin) {
    return res.status(403).json({
      error: 'forbidden',
      message: 'Недостаточно прав для удаления этого ресурса',
    });
  }
  await post.destroy();
  res.status(204).end();
});

// роли: проверяем по массиву roles[]
function requireRole(...roles) {
  return (req, res, next) => {
    const has = roles.some(r => req.user.roles?.includes(r));
    if (!has) {
      return res.status(403).json({
        error: 'forbidden',
        message: `Требуется одна из ролей: ${roles.join(', ')}`,
      });
    }
    next();
  };
}
app.js (CORS)было
// cors без опций — credentials не выставляется
// браузер блокирует preflight → выглядит как 403
app.use(cors());
app.use(express.json());
app.js (CORS)стало
app.use(cors({
  origin: [process.env.FRONT_URL, process.env.ADMIN_URL],
  credentials: true,            // Allow-Credentials для cookie/Authorization
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization'],
}));
app.use(express.json());

500 — asyncHandler и глобальный error-handler

Корневая причина — Express 4.x не ловит rejected promises из async-обработчиков автоматически. Введена обёртка asyncHandler и единый error-handler, который логирует ошибку с correlation id и отдаёт структурированный ответ.

routes (пример обработчика)было
// reject улетает в unhandledRejection → 500 без тела
router.get('/profile', auth, async (req, res) => {
  const profile = await Profile.findOne({ where: { userId: req.user.id } });
  // если profile === null:
  res.json(profile.settings.theme); // Cannot read property 'settings' of null
});

// глобального error-handler нет вовсе
asyncHandler.jsстало
// обёртка: пробрасывает async-ошибки в error-handler
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

module.exports = asyncHandler;
error.handler.jsстало
// глобальный error-handler — регистрируется ПОСЛЕ всех роутов
function errorHandler(err, req, res, next) {
  const status = err.status || 500;
  const correlationId = req.id || crypto.randomUuid();

  logger.error({
    correlationId,
    status,
    message: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method,
  });

  return res.status(status).json({
    error: status >= 500 ? 'internal_error' : err.code || 'error',
    message: status >= 500
      ? 'Внутренняя ошибка сервера. Уже чиним.'
      : err.message,
    correlationId, // клиент шлёт его в поддержку — ищем в логах за секунды
  });
}

module.exports = errorHandler;

Обработчики переписаны через asyncHandler, а на потенциально-null поля добавлены проверки, возвращающие осмысленный 404 вместо 500.

404 — порядок роутов и явные методы

Catch-all стоял выше бизнес-роутера. Часть методов была зарегистрирована как GET, а клиент слал POST. Выстроил порядок и привёл методы в соответствие контракту.

app.jsбыло
// catch-all стоит ДО бизнес-роутов → всё уходит в 404
app.use('*', (req, res) => {
  res.status(404).send('Not found');
});

app.use('/api', routes); // никогда не срабатывает

// в роутере метод не тот
router.get('/login', loginHandler); // клиент шлёт POST
app.jsстало
// 1. Бизнес-роуты — первыми
app.use('/api', routes);

// 2. CORS-preflight для неизвестных путей
app.options('*', cors());

// 3. Catch-all — последним, отдаёт структурированный 404
app.use((req, res) => {
  res.status(404).json({
    error: 'not_found',
    message: `Маршрут ${req.method} ${req.path} не найден`,
  });
});

// 4. Глобальный error-handler — самым последним
app.use(errorHandler);

// в роутере методы приведены в соответствие контракту
router.post('/login', asyncHandler(loginHandler));

Результат

После выкатки поочерёдно (auth → права → error-handler → роуты) каждый класс ошибок сошёл к нулю. Метрики снял за неделю до и неделю после релиза.

47/день → 0
0
500 на боевых эндпоинтах
мешанина → 0
0
некорректных 401/403
~120/нед → 0
0
404 на существующих роутах
0% → 100%
100%
покрытие глобальным error-handler

Каждый статус теперь несёт точную семантику: 401 — проблема аутентификации с кодом token_expired или token_invalid, 403 — недостаток прав с указанием требуемых ролей, 500 — логируемая ошибка с correlation id, 404 — только на несуществующих ресурсах. Фронтенд различает сценарии и корректно предлагает перевыпуск токена при 401, а не молча разлогинивает.

API отдаёт предсказуемые, документированные статусы. Падения больше не уходят в 500-пустышку — все ошибки ловятся глобальным обработчиком, логируются с correlation id и возвращают структурированное тело. Время разбора инцидента в логах сократилось с минут до секунд.
Есть похожая проблема?

Сначала найдём причину. Потом решим, что действительно нужно исправить.

Опишите симптом и что из-за него перестало работать. Я посмотрю, где искать причину и насколько задача похожа на точечный фикс.

Описать проблему ↗