Learnly
Проектирование API/Глава 15/Урок 1

Обработка ошибок: коды, структура error response

Обработка ошибок: коды, структура error response

Хорошая обработка ошибок, это уважение к разработчикам которые используют твой API. Когда что-то пошло не так, клиент должен понять: что случилось, почему, и что делать дальше.


Правильный HTTP статус-код, это уже половина дела

Первое что делает клиент, смотрит на статус-код. Если он правильный, категория проблемы понятна до чтения тела ответа.

Антипаттерн: 200 OK для всего

HTTP/1.1 200 OK

{ "success": false, "error": "User not found" }

Проблемы: мониторинг не видит ошибок, retry не сработает, клиентские библиотеки не знают что запрос упал.

Антипаттерн: 500 для всего

Разработчик видит 500 и думает сервер сломан, хотя просто забыл обязательное поле.


Структура error response

Минимальная:

HTTP/1.1 404 Not Found

{
  "error": {
    "code": "USER_NOT_FOUND",
    "message": "Пользователь с id=42 не найден"
  }
}

Полная для продакшна:

HTTP/1.1 422 Unprocessable Entity

{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Запрос содержит ошибки валидации",
    "details": [
      {
        "field": "email",
        "code": "INVALID_FORMAT",
        "message": "Неверный формат email"
      },
      {
        "field": "age",
        "code": "OUT_OF_RANGE",
        "message": "Возраст должен быть от 18 до 120",
        "context": { "min": 18, "max": 120, "provided": 15 }
      }
    ],
    "request_id": "req_abc123def456",
    "docs_url": "https://docs.example.com/errors#VALIDATION_FAILED"
  }
}

Зачем каждое поле

code, машиночитаемая константа. Клиент строит логику на ней, а не на тексте сообщения:

if (error.code === 'INSUFFICIENT_FUNDS') showTopUpModal();
if (error.code === 'CARD_EXPIRED') showUpdateCardModal();

message, человекочитаемо, может меняться без breaking change.

details, массив для валидационных ошибок. Один объект на каждое проблемное поле.

request_id, пользователь вставляет в тикет поддержки, ты находишь в логах за секунды. Так делает Stripe.

docs_url, ссылка на документацию конкретной ошибки. Сильно сокращает время решения проблем.


Реестр кодов ошибок

Заводи и документируй, это часть контракта API:

Код HTTP Описание
UNAUTHORIZED 401 Нет токена или неверный
FORBIDDEN 403 Нет прав на операцию
NOT_FOUND 404 Ресурс не существует
VALIDATION_FAILED 422 Ошибки валидации полей
DUPLICATE_ENTRY 409 Конфликт (дубликат email)
RATE_LIMIT_EXCEEDED 429 Превышен лимит запросов
PAYMENT_FAILED 402 Ошибка платежа
INSUFFICIENT_FUNDS 402 Недостаточно средств
INTERNAL_ERROR 500 Внутренняя ошибка сервера

Глобальный error handler на бэкенде

Не обрабатывай ошибки в каждом роуте, сделай централизованный middleware:

// Express error middleware (4 параметра, обязательно!)
app.use((err, req, res, next) => {
  const requestId = req.headers['x-request-id'] || crypto.randomUUID();

  // Логируем всё на сервере
  logger.error({
    requestId,
    error: err.message,
    stack: err.stack,
    url: req.url,
    method: req.method,
    userId: req.user?.id,
  });

  // Клиенту, минимум информации
  if (err.isOperational) {
    // Известные ошибки (валидация, not found), говорим что случилось
    return res.status(err.statusCode).json({
      error: {
        code: err.code,
        message: err.message,
        details: err.details || undefined,
        request_id: requestId,
      }
    });
  }

  // Неизвестные ошибки, только request_id, никакого stack trace
  res.status(500).json({
    error: {
      code: 'INTERNAL_ERROR',
      message: 'Внутренняя ошибка сервера',
      request_id: requestId,
    }
  });
});

// Кастомный класс ошибки
class AppError extends Error {
  constructor(code, message, statusCode, details) {
    super(message);
    this.code = code;
    this.statusCode = statusCode;
    this.details = details;
    this.isOperational = true;
  }
}

// Использование в роутах, просто бросаем ошибку
router.get('/users/:id', async (req, res, next) => {
  try {
    const user = await findUser(req.params.id);
    if (!user) throw new AppError('NOT_FOUND', 'Пользователь не найден', 404);
    res.json(user);
  } catch(e) { next(e); }
});

Заголовки для дополнительного контекста

Retry-After для 429 и 503

HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1710498000

Клиент знает ровно когда повторить, реализует умный backoff.

X-Request-Id в каждом ответе

app.use((req, res, next) => {
  const requestId = req.headers['x-request-id'] || crypto.randomUUID();
  req.requestId = requestId;
  res.setHeader('X-Request-Id', requestId);
  next();
});

Что показывать в 500 ошибках

Никогда не возвращай:

{
  "error": "TypeError: Cannot read property 'id' of undefined\n    at /app/src/handlers/user.js:47:12"
}

Stack trace раскрывает структуру кода. Логируй на сервере, клиенту, только request_id.


Retry стратегии для клиента

Статус Повторять? Стратегия
400 Исправь запрос
401 Условно Обнови токен, потом повтори
403 Нет прав, повтор бессмысленен
404 Ресурса нет
429 После Retry-After
500 Exponential backoff
502/503/504 Exponential backoff

Exponential backoff с jitter:

const retry = async (fn, maxAttempts = 5) => {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch(err) {
      if (attempt === maxAttempts) throw err;
      if (![429, 500, 502, 503, 504].includes(err.status)) throw err;

      const baseDelay = Math.min(1000 * Math.pow(2, attempt - 1), 30000);
      const jitter = Math.random() * 1000; // случайный сдвиг
      await sleep(baseDelay + jitter);
    }
  }
};

Jitter нужен чтобы тысячи клиентов не ударили по серверу одновременно после восстановления, эффект «thundering herd».

AI-тест
1 / 5

Каким HTTP статус-кодом следует отвечать на запросы с неверными данными валидации?