HTTP контракт для ошибок: почему статус — это не единственное поле
На примерах покажем, как связать HTTP-статусы, машинные коды ошибок, детализацию для отладки и безопасные сообщения. Разберём случаи 4xx/5xx и как договориться со сторонними клиентами.
Содержание
HTTP контракт для ошибок: почему статус — это не единственное поле
HTTP-статус сам по себе — удобный и привычный сигнал. Но на практике он редко бывает достаточным: одному и тому же статусу соответствуют десятки разных причин, каждая из которых требует разной реакции клиента, разной логики ретраев и разных мер безопасности. Если вы ограничиваетесь только статусом, вы почти наверняка столкнётесь с ситуациями вроде: клиент “видит 400”, но не понимает, что именно сломалось; поддержка получает логи без контекста; интеграции становятся хрупкими; а ошибки, предназначенные только для диагностики, утекут наружу.
Хороший подход — рассматривать ошибки как контракт между сервером и клиентом: набор полей и правил, которые позволяют машине и человеку принять правильное решение. В этом материале разберём, как связать HTTP-статусы, машинные коды ошибок, детализацию для отладки и безопасные сообщения. Поговорим про типовые сценарии 4xx/5xx, и отдельно — как договариваться со сторонними клиентами, чтобы не “сломать” интеграцию следующей версией API.
Что такое “HTTP контракт для ошибок”
Контракт — это не только схема ответа. Это ещё и правила:
- какие статусы вы используете и за какие нарушения;
- какие машинные коды ошибок (например,
ERR_INVALID_EMAIL) идут вместе с ними; - какие поля предназначены для автоматической обработки клиентами;
- какие поля безопасны для отображения пользователю;
- как вы включаете детали для отладки, но не раскрываете внутренности;
- как соотносятся логи сервера и ответы клиента (корреляция);
- что меняется в контракте совместимо, а что — только с версионированием.
Почему одного статуса мало
HTTP-статус — это уровень семантики “в целом”:
400 Bad Request— “запрос некорректный”401 Unauthorized— “не аутентифицирован”403 Forbidden— “нет прав”404 Not Found— “не найдено”409 Conflict— “конфликт”422 Unprocessable Entity— “синтаксис ок, но смысл не прошёл валидацию”500 Internal Server Error— “что-то пошло не так”503 Service Unavailable— “временно недоступно”
Но клиенту часто нужна конкретика: ошибка в поле email, ограничение по длине, истёк токен, конфликт идемпотентности, недоступен downstream-сервис, ошибка сериализации, сбой валидации бизнес-правил и т.д.
Без машинного кода клиент вынужден:
- парсить текст сообщения (что плохо и ломается при локализации);
- гадать по статусу и контексту;
- держать много эвристик “если статус 400 и путь /users — значит...”.
В итоге растёт стоимость поддержки интеграций.
Базовый формат ошибки: поля, которые реально работают
Начнём с практичного шаблона. Для большинства API удобна структура, похожая на RFC-подобные “проблемы” (в духе Problem Details for HTTP APIs, но можно адаптировать под себя).
Пример ответа для ошибок:
{
"error": {
"type": "https://api.example.com/errors/invalid-request",
"code": "ERR_INVALID_REQUEST",
"message": "Проверьте поля запроса и повторите попытку.",
"details": {
"fieldErrors": [
{ "field": "email", "message": "Неверный формат" },
{ "field": "age", "message": "Значение должно быть >= 18" }
]
},
"correlationId": "b7f2b7a0-8f7f-4a1b-9c5f-3e2f0b4a6b31"
}
}
Разберём поля:
code— машинный стабильный идентификатор ошибки. Его лучше использовать в бизнес-логике клиента.message— безопасное человекочитаемое пояснение. Оно не должно раскрывать внутренности (SQL, stack trace, имена таблиц).details— дополнительные сведения для отладки/интерактивного UX. Может содержать “поле → причина”, но границы безопасности важны.correlationId— идентификатор, который клиент возвращает обратно (в саппорт или в повторных запросах), а сервер использует для поиска в логах.type(опционально) — ссылка на документацию ошибки или категоризацию. Хорошо, если клиентам нужно понимать “тип проблем” стандартизировано.
Минимальный набор vs расширенный
На зрелых API обычно есть два слоя:
- минимальный (стабильный, для программной обработки):
code,message(илиuserMessage), возможноcorrelationId; - расширенный (для диагностики):
details, список полей, downstream-идентификаторы, ссылки на примеры.
Разделяйте их по аудитории: всё, что можно приложить клиенту без риска, уходит в ответ. Всё, что нельзя — остаётся в логах, но “подсвечивается” корреляцией.
Как связать HTTP-стататусы и коды ошибок
Ключевая идея: статус отвечает транспортной/протокольной семантике, а код описывает доменную причину.
Таблица: типовые сочетания 4xx/5xx и коды
Ниже — пример “разумных дефолтов”. Это не универсальный стандарт, но хорошая отправная точка.
4xx: ошибки клиента
- 400 Bad Request
ERR_INVALID_JSON— невалидный JSON / неверная кодировкаERR_INVALID_SYNTAX— нарушена структура (например, отсутствует обязательный блок)
- 401 Unauthorized
ERR_AUTH_MISSING— нет заголовка аутентификацииERR_AUTH_EXPIRED— токен истёкERR_AUTH_INVALID— токен подпорчен/не проходит валидацию
- 403 Forbidden
ERR_PERMISSION_DENIED— нет правERR_SCOPE_INSUFFICIENT— недостаточно scope/roles
- 404 Not Found
ERR_RESOURCE_NOT_FOUND— нет ресурса- (иногда)
ERR_RESOURCE_INACCESSIBLE— ресурс скрыт из соображений безопасности (см. ниже)
- 409 Conflict
ERR_CONFLICT— конфликт состояния (например, версия документа изменилась)ERR_IDEMPOTENCY_CONFLICT— конфликт идемпотентности/повтор с другим телом
- 422 Unprocessable Entity
ERR_VALIDATION_FAILED— нарушены бизнес-валидацииERR_CONSTRAINT_VIOLATION— нарушено правило домена (не синтаксис)
Подводный камень: многие API используют
400“за всё”. Но если вы хотите, чтобы клиенты делали корректные действия (например, ретраить/не ретраить), стоит различать “невалидный запрос” и “валидация домена”, хотя оба окажутся 4xx.
5xx: ошибки сервера
- 500 Internal Server Error
ERR_INTERNAL— непредвиденная ошибкаERR_UNEXPECTED_STATE— “невозможная” ветка
- 502 Bad Gateway
ERR_UPSTREAM_BAD_RESPONSE— downstream вернул некорректный ответ
- 503 Service Unavailable
ERR_SERVICE_UNAVAILABLE— перегрузка/пауза/maintenanceERR_DEPENDENCY_UNAVAILABLE— недоступен критичный dependency
- 504 Gateway Timeout
ERR_UPSTREAM_TIMEOUT— таймаут при обращении к downstream
Рекомендация по стабильности
codeдолжен быть стабильным. Если меняется формат деталей (details), пустьcodeостанется прежним.messageможно локализовать или менять формулировку, но клиент не должен на него опираться.type/ссылка на документацию (если есть) должна не ломаться при реорганизациях.
Детализация для отладки vs безопасные сообщения
Это самый частый конфликт интересов: разработчикам нужно знать подробности, а безопасности — минимизировать раскрытие.
Два уровня сообщений: user-facing и debug-facing
Один из практичных шаблонов — хранить в ответе два слоя:
message(илиuserMessage) — безопасное объяснение, предназначенное пользователю/клиенту приложения;debug— опционально, только для доверенных клиентов или включается флагом (например, если запрос содержитX-Debug-Idили вы сделали whitelisting).
Пример:
{
"error": {
"code": "ERR_VALIDATION_FAILED",
"message": "Некорректные данные запроса.",
"details": {
"fieldErrors": [
{ "field": "email", "message": "Неверный формат" }
]
},
"correlationId": "b7f2b7a0-8f7f-4a1b-9c5f-3e2f0b4a6b31"
}
}
И отдельно (для доверенного режима) — расширенная ветка:
{
"error": {
"code": "ERR_UPSTREAM_TIMEOUT",
"message": "Сервис временно недоступен.",
"correlationId": "5a1c3a1b-9e1e-4b0a-9e6b-1f0a2d3c4b5a",
"debug": {
"upstream": "payments-service",
"timeoutMs": 3000
}
}
}
Что нельзя отправлять в details
Практический минимум, который лучше запретить политикой:
- SQL-запросы, имена таблиц/колонок, фрагменты схемы;
- stack trace, классы исключений, имена модулей;
- внутренние URL, адреса хостов, токены;
- “почему именно так” в логике авторизации, если это позволяет атакующему угадывать существование ресурсов.
Баланс: поле за полем
Иногда “подробности” — это просто уточнение формата. Это обычно безопасно:
- какие поля не прошли валидацию,
- какие диапазоны ожидаются,
- какие ограничения по формату.
Опасно — “всё про то, как устроено внутри”.
Примеры контрактов для распространённых сценариев
Ниже несколько кейсов с разными 4xx/5xx и соответствующими кодами.
1) Невалидный JSON: 400 + ERR_INVALID_JSON
POST /v1/users
Content-Type: application/json
{ "email": "a@", }
Ответ:
{
"error": {
"code": "ERR_INVALID_JSON",
"message": "Некорректный формат JSON.",
"correlationId": "a2e9a6aa-4f9c-4aa2-8b6e-9e6d0d1c2b3a"
}
}
Тут details обычно не нужен: парсер уже упал, и лучше не пытаться “угадать” позицию, которая может различаться между парсерами.
2) Валидация домена: 422 + ERR_VALIDATION_FAILED
Запрос валиден синтаксически, но бизнес-правила не прошли:
POST /v1/accounts/activate
{
"userId": "123",
"activationCode": "12",
"consent": false
}
Ответ:
{
"error": {
"code": "ERR_VALIDATION_FAILED",
"message": "Проверьте данные активации.",
"details": {
"fieldErrors": [
{ "field": "activationCode", "message": "Должно быть 6 символов" },
{ "field": "consent", "message": "Требуется согласие" }
]
},
"correlationId": "c0f1a2b3-c4d5-4e6f-9a0b-1c2d3e4f5a6b"
}
}
Обратите внимание: клиенту достаточно code + fieldErrors, чтобы подсветить форму и не перебирать варианты.
3) Нет прав: 403 + ERR_PERMISSION_DENIED
{
"error": {
"code": "ERR_PERMISSION_DENIED",
"message": "Недостаточно прав для выполнения действия.",
"correlationId": "d1e2f3a4-b5c6-4d7e-8f90-a1b2c3d4e5f6"
}
}
Не пытайтесь сообщать “ресурс существует, но вам нельзя”. Если существует риск утечки — формулируйте нейтрально.
4) Конфликт состояния: 409 + ERR_CONFLICT
Например, оптимистическая блокировка по версии:
PUT /v1/documents/55
If-Match: "3"
{
"title": "Новый заголовок"
}
Если версия изменилась:
{
"error": {
"code": "ERR_CONFLICT",
"message": "Конфликт версий. Обновите данные и повторите попытку.",
"details": {
"currentVersion": 4
},
"correlationId": "f1e2d3c4-b5a6-4f78-9a01-b2c3d4e5f6a7"
}
}
Клиент может принять решение: либо перечитать ресурс, либо показать сообщение пользователю.
5) Ошибка downstream: 503 + ERR_DEPENDENCY_UNAVAILABLE
Сервис зависит от внешнего платежного шлюза.
Если downstream лежит:
{
"error": {
"code": "ERR_DEPENDENCY_UNAVAILABLE",
"message": "Платёжный сервис временно недоступен.",
"correlationId": "3b5a7c9d-1e2f-4a3b-8c6d-9e0f1a2b3c4d"
}
}
Если вы хотите поддержать ретраи, добавьте HTTP заголовок Retry-After (это уже отдельный контракт на уровне протокола):
HTTP/1.1 503 Service Unavailable
Retry-After: 10
А code позволит клиентам отличить “временно недоступно” от “невозможно выполнить”.
Как действовать с 4xx и 5xx: ретраи, идемпотентность и “правильные” статусы
В контракте ошибок важны не только поля, но и ожидаемое поведение клиента.
Что обычно ретраить
408 Request Timeout(иногда)429 Too Many Requests— часто сRetry-After502/503/504— ретраи с backoff
В этих случаях message может быть нейтральным, но code лучше сделать предсказуемым.
Пример для rate limit:
{
"error": {
"code": "ERR_RATE_LIMITED",
"message": "Слишком много запросов. Попробуйте позже.",
"correlationId": "..."
}
}
И соответствующие заголовки:
HTTP/1.1 429 Too Many Requests
Retry-After: 30
Что обычно НЕ ретраить
400/422— “клиент должен исправить”401/403— “нужна авторизация/права”404— “нет ресурса” или “недоступен” (зависит от политики)
Если клиент всё равно ретраит из-за неверной интерпретации, вы получите лавину нагрузки — поэтому договорённость важна.
Идемпотентность и 409
Для POST/PUT с идемпотентностью полезно иметь отдельный код. Например:
ERR_IDEMPOTENCY_CONFLICT— повтор с тем же ключом, но разными теламиERR_IDEMPOTENCY_EXPIRED— ключ истёк
Так вы даёте клиенту шанс корректно разобраться в ситуации, вместо бесконечных повторов.
Как договориться со сторонними клиентами и не сломать интеграции
Интеграции чаще всего ломаются не из-за “неверного статуса”, а из-за изменения формы ответа и логики трактовки ошибок. Чтобы этого избежать, нужны принципы.
1) Версионируйте контракт ошибок
Есть два распространённых подхода:
- Версионировать API целиком (например,
/v1,/v2) и держать контракт ошибок совместимым внутри версии. - Версионировать формат ошибок внутри поля, например
error.version: 1.
В практическом смысле проще всего — держать контракт совместимым внутри API версии и обновлять его только при major-изменениях.
2) Правило совместимости: добавляй, но не переименовывай
- Добавляйте новые коды ошибок — это обычно безопасно.
- Не удаляйте и не переименовывайте старые коды.
- Если вы меняете семантику поля (например, теперь
details.fieldErrorsимеет другой формат), лучше ввести новое поле или новыйcode.
3) Document your codes
“Документация” — это не отдельная страница на 200 строк. Достаточно:
- перечня
code(минимум: смысл, какие статусы возможны, какие поляdetailsприходят); - примера ответа;
- рекомендаций для клиентов (“retry / do not retry”).
Для интеграций это критично: иначе
Комментарии
Пока нет комментариев