Большинство разработчиков пишут API снаружи внутрь: сначала happy path — то, что работает, потом, если время остаётся, добавляют обработку ошибок. Это ошибка. Ошибки в API — не исключение, а часть контракта. Мобильное приложение, внешний сервис, фронтенд — все они полагаются на то, что API говорит понятно не только когда всё хорошо, но и когда что-то пошло не так.

Плохо спроектированные ошибки — это четыре часа отладки, когда в ответ приходит 200 OK с телом {"success": false} и никакого намёка, что именно сломалось. Или 500 Internal Server Error на валидацию формы. Или строка "error" в поле, которое должно быть числом.

HTTP-статусы — не просто цифры

Первый и самый базовый инструмент — HTTP-статус. Его назначение часто игнорируют: возвращают 200 на всё подряд и кладут информацию об ошибке в тело. Это удобно писать, но неудобно читать.

Короткая шпаргалка:

2xx — успех

  • 200 OK — запрос выполнен, данные в ответе
  • 201 Created — ресурс создан (после POST)
  • 204 No Content — выполнено, данных нет (например, при DELETE)

4xx — ошибка клиента

  • 400 Bad Request — некорректные данные в запросе
  • 401 Unauthorized — нет или невалидный токен
  • 403 Forbidden — токен есть, но доступа нет
  • 404 Not Found — ресурс не существует
  • 409 Conflict — конфликт состояния (например, email уже занят)
  • 422 Unprocessable Entity — данные технически корректны, но не проходят валидацию
  • 429 Too Many Requests — превышен лимит запросов

5xx — ошибка сервера

  • 500 Internal Server Error — что-то сломалось на сервере
  • 502 Bad Gateway — проблема с upstream-сервисом
  • 503 Service Unavailable — сервис временно недоступен

Разница между 401 и 403 многих путает. Простое правило: 401 — «я тебя не знаю, покажи документы», 403 — «знаю тебя, но сюда нельзя».

Разница между 400 и 422 тоже нетривиальна. 400 — запрос синтаксически кривой (невалидный JSON, не тот Content-Type). 422 — JSON пришёл нормально, но поле email не похоже на email. Многие используют только 400 для обоих случаев — это допустимо, если вы консистентны внутри одного API.

Структура тела ошибки

Статус-кода недостаточно. Клиенту нужно понять: что именно пошло не так, почему, и что с этим делать. Для этого нужно тело ошибки.

Хорошая структура выглядит примерно так:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Некорректные данные запроса",
    "details": [
      {
        "field": "email",
        "message": "Некорректный формат email"
      },
      {
        "field": "password",
        "message": "Пароль должен содержать минимум 8 символов"
      }
    ],
    "request_id": "req_a1b2c3d4"
  }
}

code — машиночитаемый код ошибки. Важно, чтобы он был постоянным и задокументированным. Фронтенд или мобильное приложение может показывать разные сообщения в зависимости от кода — переведённые, адаптированные под пользователя. message меняться может, code — нет.

message — человекочитаемое описание. Избегайте технических деталей вроде SQLException: duplicate key value violates unique constraint — это утечка внутренних деталей и дыра в безопасности.

details — список конкретных проблем, особенно при валидации. Если пришли пять некорректных полей, скажите об этом сразу. Не заставляйте пользователя исправлять по одному.

request_id — идентификатор запроса. Когда пользователь пишет в поддержку «у меня ошибка», этот id позволяет найти конкретный запрос в логах за секунды.

Антипаттерны, которые встречаются везде

200 OK с ошибкой внутри

HTTP 200 OK
{
  "success": false,
  "error": "User not found"
}

Мониторинг, логи и кеши ориентируются на HTTP-статус. Если всё возвращает 200, нельзя настроить алёрты на ошибки на уровне инфраструктуры. Nginx, CloudFront, Cloudflare — они не знают, что у вас внутри success: false. Метрики будут показывать зелёный цвет, пока пользователи получают ошибки.

Разные форматы ошибок в одном API

В одном эндпоинте {"error": "not found"}, в другом {"message": "error", "code": 404}, в третьем просто строка "Unauthorized". Это катастрофа для тех, кто пишет клиент. Приходится обрабатывать каждый случай отдельно.

Решение простое: один формат для всего API, зафиксированный в документации в первый день проекта.

Слишком мало информации

HTTP 400
{ "error": "Bad request" }

Что именно не так? Какое поле? Что ожидалось? Это отладка вслепую.

Слишком много информации

HTTP 500
{
  "error": "NullPointerException at com.example.service.UserService.findById(UserService.java:142)"
}

Другая крайность. Стек-трейс в ответе API — утечка реализации. По нему можно определить стек технологий, найти уязвимости, составить карту сервисов. В продакшене стек-трейс должен уходить только в логи, не в ответ клиенту.

Игнорирование rate limiting

Если не возвращать 429 Too Many Requests с заголовком Retry-After, клиенты будут бомбардировать сервер в retry-цикле и усугублять ситуацию. Правильный ответ:

HTTP 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1714070400

{
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Превышен лимит запросов. Попробуйте через 30 секунд."
  }
}

Как обрабатывать ошибки на стороне клиента

Проектирование ошибок — половина дела. Вторая половина — как клиент их обрабатывает.

Не глотайте ошибки. Самая частая проблема в JavaScript:

try {
  const data = await fetchUser(id);
  setUser(data);
} catch (e) {
  // TODO: handle error
  console.log(e);
}

console.log — это не обработка. Пользователь видит бесконечный спиннер. Как минимум показывайте сообщение об ошибке.

Разделяйте типы ошибок. Ошибка сети (нет интернета), ошибка клиента (400–499) и ошибка сервера (500+) — разные ситуации с разными реакциями. Для ошибок сети: «Проверьте подключение к интернету». Для 401 — редирект на страницу входа. Для 500 — «Что-то пошло не так, мы уже разбираемся».

Retry-логика только для нужных кодов. Повторять запрос имеет смысл при 429 (с задержкой из Retry-After), 502, 503, 504. Повторять 400 или 404 бессмысленно — ответ не изменится. Повторять 401 без обновления токена — тоже.

async function fetchWithRetry(url, options, maxRetries = 3) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    const response = await fetch(url, options);

    if (response.status === 429) {
      const retryAfter = response.headers.get('Retry-After') || 5;
      await sleep(retryAfter * 1000);
      continue;
    }

    if (response.status >= 500 && attempt < maxRetries - 1) {
      await sleep(Math.pow(2, attempt) * 1000); // exponential backoff
      continue;
    }

    return response;
  }
}

Логируйте ошибки на фронтенде. Sentry или аналог. Важно логировать не только факт ошибки, но и контекст: что делал пользователь, какие данные отправлял (без персональных). Это сокращает время отладки в разы.

RFC 7807 — стандарт, о котором мало знают

Если хотите опереться на стандарт, есть RFC 7807 — «Problem Details for HTTP APIs». Он определяет формат тела ошибки:

{
  "type": "https://example.com/errors/validation-error",
  "title": "Ошибка валидации",
  "status": 422,
  "detail": "Поле email обязательно",
  "instance": "/users/register"
}

type — URI, описывающий тип ошибки (может быть ссылкой на документацию). instance — конкретный URL, при обращении к которому возникла ошибка. RFC 7807 используют некоторые сервисы Microsoft, платёжные системы и банковские API. Если проектируете публичный API или работаете с внешними партнёрами — стоит рассмотреть.

Документация ошибок

Часто в документации API описывают только happy path. Хорошая документация описывает:

  • Какие статус-коды возвращает каждый эндпоинт
  • Какие коды ошибок (code) возможны
  • Что означает каждый код
  • Как клиенту на него реагировать

Пример таблицы:

Код Статус Описание Действие клиента
VALIDATION_ERROR 422 Некорректные данные Показать ошибки пользователю
NOT_FOUND 404 Ресурс не существует Страница 404
UNAUTHORIZED 401 Невалидный токен Обновить токен или разлогинить
RATE_LIMIT_EXCEEDED 429 Превышен лимит Повторить через Retry-After секунд

Коды ошибок — это публичный контракт. Никогда не удаляйте их и не меняйте их смысл без мажорного версионирования API. Добавлять новые поля в тело — безопасно, переименовывать существующие — нет.

Тестирование обработки ошибок

Ошибки нужно тестировать так же, как и успешные сценарии.

Юнит-тесты для каждого кода. Проверяйте, что при конкретных условиях возвращается правильный статус и правильная структура тела. Это занимает 10 минут сейчас и экономит часы потом.

Contract testing. Инструменты вроде Pact позволяют зафиксировать контракт между сервисами — включая формат ошибок. Если API меняет структуру ответа, тест ломается до деплоя, а не после.

Намеренно ломайте зависимости. Отключите базу данных — должен прийти 503, не 500. Сделайте так, чтобы внешний сервис отвечал с таймаутом — клиент должен получить понятный ответ, а не зависнуть на 30 секунд.

Смотрите на продакшен-ошибки регулярно. Если 400 сыплется тысячами на один эндпоинт — либо клиент делает что-то не так, либо API неправильно задокументирован. Оба варианта требуют реакции.

В REEXY при проектировании API для клиентских проектов формат ошибок фиксируется в самом начале — до написания первого эндпоинта. Это снимает целый класс вопросов при интеграции фронтенда и значительно упрощает дальнейшую поддержку.

Хорошо спроектированные ошибки — это не педантизм. Это уважение к тем, кто будет интегрироваться с вашим API: к команде фронтенда, к партнёрам, к себе через полгода. Когда ошибка говорит точно, что пошло не так и что с этим делать, отладка превращается из квеста в рутину.