lenec ru

← все посты

Стиль ошибок API: что вернуть в 400 и как это документировать

14K

API возвращает 400 Bad Request с телом {"error":"invalid"}. Клиент видит это и не понимает: что именно невалидно, какое поле, что показать пользователю в форме. Через неделю таких ошибок в саппорт прилетает «у вас сайт сломан» — и фронтенд-разработчик идёт читать исходники бэкенда, потому что в доках на эндпоинт описан только happy path.

Ошибки в API — это половина контракта. Если их не описать, интеграция превращается в реверс-инжиниринг. Разберу, какой формат ошибок реально работает в продакшене, что писать в теле 4xx ответов и как это документировать так, чтобы клиентский разработчик не переспрашивал.

Почему «error: string» не работает

Самая частая схема, которую вижу в чужих API:

{
  "error": "Validation failed"
}

Проблем сразу несколько. Клиент не знает, какое поле сломалось — нельзя подсветить input в форме. Текст приходит уже локализованный (или, наоборот, на английском), и фронтенд не может его перевести. Если ошибок несколько, они склеены в одну строку. И главное — структура ответа на ошибку отличается от структуры ответа на успех, поэтому клиент пишет два разных парсера.

Второй частый вариант — отдавать HTML или plain text от веб-сервера. Это случается, когда middleware обрабатывает 500 раньше, чем контроллер. JSON-клиент падает на парсинге, в логах появляется «Unexpected token < in JSON at position 0», и команда тратит день, чтобы понять, что дело в reverse proxy.

Что должно быть в теле ошибки

Минимум, который покрывает 90% случаев:

  • code — стабильный машинный идентификатор. Не меняется между релизами. Клиент использует его для логики (показать модалку «Пополните баланс» или редирект на логин).
  • message — человекочитаемое описание. На языке API (обычно английский), без подстановок пользовательских данных.
  • field — для ошибок валидации: имя поля. Через точку для вложенных: user.address.zip.
  • details — опциональный объект с контекстом. Например, {"min": 8, "actual": 4} для ошибки длины пароля.

Было:

{
  "error": "Password too short"
}

Стало:

{
  "error": {
    "code": "password_too_short",
    "message": "Password must be at least 8 characters",
    "field": "password",
    "details": { "min": 8, "actual": 4 }
  }
}

Теперь фронтенд может: показать сообщение из message, подсветить поле password, локализовать ошибку через code (свой словарь password_too_short → «Пароль слишком короткий»), и при желании показать «нужно ещё 4 символа», вычитав details.

RFC 7807: problem+json

Для публичных API стоит посмотреть на RFC 7807 — стандарт «Problem Details for HTTP APIs». Содержит готовую схему: type (URI с описанием класса ошибки), title, status, detail, instance. Content-Type — application/problem+json.

{
  "type": "https://api.example.com/errors/insufficient-funds",
  "title": "Insufficient funds",
  "status": 402,
  "detail": "Account 12345 has balance 50, requested 100",
  "instance": "/transfers/abc-123",
  "balance": 50,
  "requested": 100
}

Плюс — у клиентов есть библиотеки под этот формат. Минус — поле для имени невалидного поля в стандарте не определено, его дописывают как расширение. Если делаешь публичный B2B API с клиентами на разных языках, RFC 7807 — разумный выбор. Для внутреннего API проще обойтись своей схемой, главное — чтобы она была одна на все эндпоинты.

Несколько ошибок одновременно

Форма с десятью полями. Юзер заполнил пять с ошибками. Если возвращать первую найденную, он будет тыкать «отправить» пять раз. Поэтому для валидации — массив:

{
  "error": {
    "code": "validation_failed",
    "message": "Request validation failed",
    "errors": [
      {
        "code": "required",
        "field": "email",
        "message": "Email is required"
      },
      {
        "code": "out_of_range",
        "field": "age",
        "message": "Age must be between 18 and 120",
        "details": { "min": 18, "max": 120, "actual": 5 }
      }
    ]
  }
}

Внешняя обёртка с общим code остаётся — клиент по ней понимает, что это валидация, и идёт парсить errors. Если ошибка одна и не валидационная (нет прав, не найдено), массива нет.

Какой статус-код ставить

Список, которым пользуюсь сама и которому учу команды:

  • 400 — тело запроса невалидное: не парсится JSON, не сходится схема, нарушение бизнес-правил при валидации входа.
  • 401 — нет токена или токен невалидный/просрочен. Клиент должен пойти логиниться.
  • 403 — токен есть, юзер аутентифицирован, но прав на это действие нет. Клиент логиниться не идёт.
  • 404 — ресурса нет. Не используй для «нет прав» — это маскировка, путает клиентов и ломает кеши.
  • 409 — конфликт состояния: дубликат email при регистрации, изменение версии (optimistic locking).
  • 422 — синтаксис верный, но семантика бизнеса нарушена. Споры «400 vs 422» бесконечны, выбери один и придерживайся. Я обычно беру 400 для всего, кроме явных доменных конфликтов.
  • 429 — rate limit. Обязательно с заголовком Retry-After.
  • 500 — у тебя баг. Не клади сюда «не нашли пользователя».

Главное правило: один и тот же класс ошибки всегда возвращает один и тот же код. Если «email занят» иногда 409, иногда 400, иногда 422 — клиент не сможет написать общий обработчик.

Идентификатор запроса в каждой ошибке

На каждый 5xx и часть 4xx добавляй request_id. Это та же строка, что попадает в логи бэкенда. Когда клиент пишет в саппорт «не работает оплата», он присылает request_id, и инженер за пять секунд находит трейс в Loki/Sentry/чём-то ещё.

{
  "error": {
    "code": "internal_error",
    "message": "Something went wrong",
    "request_id": "01HZK9NVGM3T6QHFBJ8R4Y2C7P"
  }
}

То же самое лучше дублировать в заголовок X-Request-Id — тогда CDN и прокси его сохранят, даже если тело по какой-то причине не дойдёт.

Документация: схема + примеры + список кодов

Описание ошибок в OpenAPI часто выглядит так:

responses:
  '400':
    description: Bad Request

Это не документация. Это галочка. Клиент по такой странице не сможет ни написать обработку, ни локализовать сообщения. Минимум, что нужно:

components:
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: Stable machine-readable error code
            message:
              type: string
              description: Human-readable message in English
            field:
              type: string
              description: Field name for validation errors
            details:
              type: object
              additionalProperties: true
            request_id:
              type: string

paths:
  /users:
    post:
      responses:
        '400':
          description: Validation failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                emailTaken:
                  summary: Email already registered
                  value:
                    error:
                      code: email_taken
                      message: Email is already registered
                      field: email
                passwordTooShort:
                  summary: Password too short
                  value:
                    error:
                      code: password_too_short
                      message: Password must be at least 8 characters
                      field: password
                      details: { min: 8, actual: 4 }

Дальше — отдельная страница «Error codes» в доках, где собраны все возможные значения code с пояснением и рекомендацией для клиента. Эту страницу можно генерировать из OpenAPI, если в каждом примере проставлен code: парсишь examples, собираешь уникальные значения, рендеришь Markdown.

Пример строки в такой таблице:

  • email_taken — 409 — пользователь с таким email уже зарегистрирован. Клиенту: предложить вход или восстановление пароля.
  • password_too_short — 400 — пароль короче details.min символов. Клиенту: показать ошибку под полем.
  • token_expired — 401 — JWT просрочен. Клиенту: обновить токен через refresh или редирект на логин.

Чего не делать

Не подставлять пользовательский ввод в message без экранирования. "User 'admin'); DROP TABLE...' not found" — известный способ получить XSS, если клиент рендерит сообщение как HTML. Подставляй данные в details, а message держи статичной.

Не менять code между минорными версиями. Если переименовываешь — старое значение оставь как алиас минимум на полгода, а в changelog напиши явно. Я видела, как переименование not_found в resource_not_found сломало мобильное приложение, потому что обработчик был завязан на точное совпадение строки.

Не возвращать stacktrace в проде. Никогда. Даже под флагом «debug=true» в проде. Стектрейс — это карта внутренней структуры приложения, и попадание её в публичный API — это утечка. Для отладки есть request_id и серверные логи.

Не использовать 200 OK с {"success": false}. Это любимая схема старого PHP-API. Кеши, прокси и мониторинги считают такой ответ успехом, метрики ошибок ломаются, retry-логика не срабатывает. HTTP-семантика существует — пользуйся ей.

Чек-лист, что проверить в своём API

  • Тело ошибки — всегда JSON с одной и той же схемой во всех эндпоинтах.
  • Есть стабильный code, а не только message.
  • Для валидации — массив errors с field в каждом.
  • Для 5xx и важных 4xx есть request_id, он же в заголовке X-Request-Id.
  • В OpenAPI описана схема Error и хотя бы один-два examples на каждый 4xx.
  • Существует страница «Error codes» со всеми возможными code.
  • HTTP-статусы расставлены последовательно, один класс ошибок — один статус.
  • В message нет пользовательского ввода без экранирования.

Хорошо описанные ошибки — это то, что делает API приятным для интеграции. На happy path API похожи друг на друга, отличия начинаются на ошибках. Клиент, который один раз залип на «error: invalid», запоминает это надолго. И наоборот — четкая схема и страница с кодами обычно превращают часть тикетов в саппорт в самостоятельные правки на стороне клиента.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.