Как читать и писать REST API контракты: статусы, ошибки и схемы ответов
Разберём типовые ошибки в дизайне API и научимся фиксировать контракт: коды HTTP, формат ошибок, версионирование и примеры запросов/ответов.
Содержание
Как читать и писать REST API контракты: статусы, ошибки и схемы ответов
REST API контракты — это договор между клиентом и сервером. Он редко выглядит как документ в PDF, чаще — как набор правил: какие статусы отдаём, как форматируем ошибки, как версионируем API, какие поля и в каком виде возвращаем. Хороший контракт снижает стоимость разработки: клиенту не нужно гадать, серверу — “угадывать”, что имел в виду фронтенд. Плохой контракт приводит к бесконечным “а давайте добавим ещё одно поле”, хаотичным обработчикам и росту количества несовместимых релизов.
Ниже разберём, как читать REST API контракт (т.е. оценивать качество и предсказуемость по тому, как он описан) и как писать контракт самим: коды HTTP, единый формат ошибок, схемы ответов, версионирование и примеры запросов/ответов. Поймём, где чаще всего ломается взаимодействие и как это фиксировать документально.
Что такое “контракт” REST API и почему он важнее “эндпоинтов”
В REST часто говорят про “эндпоинты”: GET /users, POST /orders. Но на практике контрактом становится:
- Семантика методов (что означает
GET, что —POST, как трактуем идемпотентность). - Коды HTTP (какие сценарии какой код должны получать).
- Тела ответов (JSON schema, набор полей, допустимые типы, nullability, форматы дат).
- Ошибки (структура, единообразие, читаемость, привязка к полям/причинам).
- Поведение при ошибках (повторяемость, retry-after, side effects).
- Версионирование и эволюция (как меняем ответы без “поломки” клиентов).
- Корреляция (id запроса, trace id в заголовках) — особенно важно при инцидентах.
Контракт — это то, что клиенту нужно знать, чтобы корректно обработать успешный сценарий и “плохой”.
HTTP статусы: как читать и назначать коды правильно
Как читать контракт по статусам
Хороший контракт:
- использует статусы с предсказуемой семантикой;
- не смешивает бизнес-ошибки с HTTP “по случайности”;
- документирует, какие статусы могут прийти для каждого метода;
- не возвращает
200 OKвместо ошибок там, где ошибка очевидна (хотя иногда это встречается исторически — и это уже технический долг).
Плохой контракт:
- возвращает один и тот же код на всё (“вместо 404 всегда 200 и флаг
success”); - использует
500вместо400/409; - возвращает
201безLocation; - отдаёт
204при наличии тела ответа; - не различает “неавторизован” и “запрещено”.
Базовая матрица статусов (и самые частые ошибки)
Ниже — практическая матрица, которую удобно держать в голове при дизайне:
Успех
- 200 OK — стандартный ответ “успешно, есть содержимое”.
- 201 Created — создан ресурс. Часто вместе с
Location. - 202 Accepted — принято в обработку асинхронно, результат будет позже.
- 204 No Content — успешно, но тело отсутствует (например,
DELETE). - 206 Partial Content — редкий случай для диапазонов.
Типичная ошибка: возвращать 201, когда на самом деле “обновили”, а не создали (лучше 200 или 204, либо 409/201 — зависит от бизнес-логики).
Клиентские ошибки
- 400 Bad Request — неверный формат/валидация на уровне запроса (JSON невалиден, поля не соответствуют схеме).
- 401 Unauthorized — запрос без аутентификации или с невалидным токеном.
- 403 Forbidden — аутентификация есть, но нет прав (например, роли не подходят).
- 404 Not Found — ресурс не существует (или скрываем существование политикой).
- 405 Method Not Allowed — метод не поддерживается на данном ресурсе.
- 409 Conflict — конфликт состояния: например, попытка создать дубликат, optimistic locking, пересечение уникальности.
- 410 Gone — ресурс удалён и больше не доступен.
- 422 Unprocessable Entity — иногда используется для “семантически неверных данных” при корректном синтаксисе (это удобно, но не стандартно в духе REST-учителей; если используете — сделайте это последовательно).
Типичные ошибки:
- использовать
401вместо403(аутентификация/авторизация перепутаны); - кидать
500при проблемах в запросе клиента; - смешивать “ошибка валидации” и “бизнес-ограничение” в одном
400без деталей.
Серверные ошибки
- 500 Internal Server Error — неожиданные ошибки.
- 502/503/504 — проблемы с upstream/временная недоступность/таймауты.
Типичная ошибка: отдавать 500 при “ограничение бизнеса нарушено” — вместо 409/422.
Практический шаблон: какие статусы должны быть для каждого кейса
Хорошая практика — явно фиксировать для каждого endpoint:
- минимум “успехов” (1–2 кода);
- ошибки: “валидация”, “аутентификация”, “авторизация”, “не найден”, “конфликт”, “неизвестная ошибка”.
Пример: POST /v1/payments
201 Created400— ошибка формата/валидации401— нет/плохой токен403— нет прав409— конфликт (например, payment already exists for idempotency key)500— непредвиденное
Формат ошибок: единообразие вместо “разных сообщений”
Что должен содержать error response
Единый формат ошибок решает две задачи:
- Клиенту проще обработать ошибки программно.
- Становится возможно наблюдать и диагностировать (и в проде, и в тестах).
Обычно ошибки описывают так:
- HTTP статус (как уже обсудили);
- структура JSON с полями:
type— категория/тип ошибки (машиночитаемый идентификатор);title— краткое читаемое описание;status— дублирование статуса (чтобы не терять при проксировании);detail— расширенное объяснение (не слишком длинное);instance— идентификатор конкретной ошибки/запроса (опционально);errors— массив полевых ошибок для валидации.
Семантика type/title/detail часто берётся из RFC 7807 (“Problem Details for HTTP APIs”). Это хорошая основа даже если вы не обязаны следовать RFC на 100%.
Пример: согласованный формат ошибок
{
"type": "https://api.example.com/problems/validation-error",
"title": "Validation error",
"status": 400,
"detail": "Request body contains invalid fields.",
"instance": "6b1c9f8a-1f6c-4b6c-9b52-2f7d4c4b9f3f",
"errors": [
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "Email has an invalid format."
},
{
"field": "amount",
"code": "MIN_VALUE",
"message": "Amount must be greater than 0."
}
]
}
Ключевые детали:
errors— для валидации, когда можно привязать проблему к полям.code— машинный код, чтобы фронтенд мог делать локализацию и обработку без парсинга текста.message— человекочитаемое сообщение (обычно в одном языке или в языке сервиса; локализацию лучше делать на клиенте поcode).
Ошибка как “контракт”: статусы + типы ошибок + правила
Контракт нужно фиксировать так, чтобы любой читающий API знал:
- для
400вы отдаётеvalidation-errorи обязательно заполняетеerrors; - для
409—conflictс полямиcodeи, при необходимости,resource/conflictField; - для
401/403— какие заголовки и поля присутствуют.
Например, для 401 полезно отдавать WWW-Authenticate, а для “повторяемости” иногда добавляют Retry-After (чаще для 429/503).
Схемы ответов: как проектировать структуру данных так, чтобы её не “ломали” релизы
Минимальный набор правил для схем
В контракте нужно определить:
- Формат дат — лучше ISO 8601 (например,
2026-07-20T12:34:56Z). - Идентификаторы — типы (
string/uuid/int64), формат. - Nullable-поля — что означает
null, а чтополе отсутствует. - Enum’ы — ограниченный набор значений с документированными вариантами.
- Стабильность полей — что нельзя удалить без версионирования.
- Пустые коллекции — возвращать
[], а неnull.
Типичная ошибка: “поле есть только иногда”. Клиенту приходится городить ad-hoc проверки. Лучше разделять:
field: null(поле существует, но значение неизвестно/не применимо),- или
fieldотсутствует (поле нерелевантно) — но тогда это должно быть описано в схеме.
Пример: JSON schema на практике (без формализма, но строгостью)
Возьмём ресурс User:
Успешный ответ GET /v1/users/{id}:
200:
{
"id": "b7a5d8a0-2f6c-4d12-a0c0-5e4a0b9c1234",
"email": "alex@example.com",
"name": "Alex",
"status": "ACTIVE",
"createdAt": "2025-04-10T09:15:30Z",
"updatedAt": "2026-06-01T11:00:00Z"
}
Контракт должен уточнить:
status∈{ACTIVE, SUSPENDED, DELETED};createdAt/updatedAtвсегда есть и всегда ISO 8601;namenullable или всегда строка.
Версионирование API: как не превратить “v2” в костыль
Типы версионирования
Есть три распространённых подхода:
- URI versioning:
/v1/...,/v2/...
Плюсы: простота кэширования, ясность. Минусы: разрастание путей. - Header versioning:
Accept: application/vnd.company.api.v1+json
Плюсы: чистые пути, версия отделена от ресурса. Минусы: сложнее для тестов и документации. - Query versioning:
?version=1
Плюсы: удобно. Минусы: чаще ломает кэширование и становится хаотичным.
Для REST контрактов чаще выбирают URI или Accept header, а критическое правило одно: версия должна защищать совместимость.
Как фиксировать совместимость: “breaking change” и “non-breaking change”
-
Non-breaking изменения:
- добавили новое поле в ответ (если клиенты игнорируют неизвестное поле);
- добавили новые варианты enum (если клиенты не делают жёсткий switch без default);
- добавили новый тип ошибки (если клиенты обрабатывают “unknown type”).
-
Breaking изменения:
- изменили тип поля (string → int);
- изменили значение enum существующего варианта;
- удалили поле (кроме случаев, когда контракт это допускает);
- изменили семантику статусов/ошибок;
- поменяли формат дат/валют.
Важно: иногда изменение “только текста ошибки” тоже может считаться breaking, если клиент парсит message. Поэтому лучше просить клиентов опираться на code/type, а не на текст.
Практическая стратегия эволюции контракта
- Согласовать ошибки заранее и держать стабильно.
- Новые поля — add-only (с объяснением условий появления).
- Для enum добавляйте значения аккуратно и тестируйте клиенты с “default”.
- Для удаления поля — deprecate (пометить устаревшим) и через релизы убрать.
Как писать контракт: структура документа и “что обязательно описать”
Что включить в контракт для каждого endpoint
Для каждого метода API документируйте:
- Method + path
- Назначение (1–2 предложения)
- Authentication (требуется/нет, схема)
- Запрос:
- параметры (path/query) с типами;
- body — схема;
- правила idempotency (если есть).
- Ответы:
- список статусов;
- схема тела для каждого успеха;
- схема ошибки для
400/401/403/404/409/500.
- Побочные эффекты:
- создаёт/меняет состояние;
- идемпотентность
POST(иногда делают черезIdempotency-Key).
- Ограничения:
- лимиты, требования к размеру полей;
- rate limits (обычно
429).
Чеклист контрактного дизайнера
- Есть ли единый формат ошибок?
- Клиент может однозначно понять “что не так” (по
type/code/field)? - Для каждого сценария определён правильный HTTP статус?
- Для
201естьLocationили чёткое объяснение, почему нет? - Поля ответов стабильны: типы, nullability, даты.
- Версионирование продумано: какие изменения будут breaking?
- Есть пример запрос/ответ (минимум один успешный и один ошибочный).
Примеры запросов/ответов: как показать контракт “в действии”
Ниже — несколько коротких, но показательных кейсов. Они не претендуют на идеальность конкретного домена, зато демонстрируют механизм контракта.
Пример 1: создание ресурса с валидацией
Запрос
POST /v1/users
Content-Type: application/json
Authorization: Bearer eyJ...
{
"email": "bad-email",
"name": ""
}
Ответ
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"type": "https://api.example.com/problems/validation-error",
"title": "Validation error",
"status": 400,
"detail": "Request body contains invalid fields.",
"instance": "6b1c9f8a-1f6c-4b6c-9b52-2f7d4c4b9f3f",
"errors": [
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "Email has an invalid format."
},
{
"field": "name",
"code": "MIN_LENGTH",
"message": "Name must not be empty."
}
]
}
Контрактное уточнение, которое важно: клиент знает, что ошибки приходят в массиве errors и каждую можно привязать к полю.
Пример 2: конфликт при уникальности
Запрос
POST /v1/users
Content-Type: application/json
{
"email": "alex@example.com",
"name": "Alex"
}
Если email уникален, то повторный запрос может конфликтовать.
Ответ
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"type": "https://api.example.com/problems/conflict",
"title": "Resource conflict",
"status": 409,
"detail": "User with given email already exists.",
"instance": "2f5c8a9e-77b3-44ac-9f8a-0bb3d2aef7cc",
"errors": [
{
"field": "email",
"code": "DUPLICATE",
"message": "This email is already registered."
}
]
}
Здесь ключ — 409, а не “200 с флагом”. Клиент по коду/типу ошибки может корректно показать форму (“email уже занят”) и не пытаться “создавать заново”.
Пример 3: идемпотентный POST (если у вас платежи/создания с ретраями)
Контракт часто ломается при ретраях: сеть отвалилась, клиент не знает, создался ли ресурс. Решение — идемпотентность через заголовок.
Запрос
POST /v1/payments
Idempotency-Key: 2d5b6c1a-0b7f-4c3d-98d3-3f6d2c4d7a90
Content-Type: application/json
Authorization: Bearer eyJ...
{
"orderId": "ORD-100500",
"amount": 1999,
"currency": "RUB"
}
Ответы (варианты контракта)
- Если запрос обрабатывается впервые:
HTTP/1.1 201 Created
Location:
Комментарии
Пока нет комментариев