Error responses: RFC 7807 Problem Details и почему 200 OK с error в body — преступление
API падает. Это нормально. Запрос невалиден, БД недоступна, лимит превышен, пользователь не имеет прав. Вопрос не в том, будут ли ошибки — а в том, как API о них сообщает. Junior data engineer, который пишет ETL pipeline, столкнётся с десятками вариантов: одни API возвращают 200 OK с {"error": "..."} в теле (преступление), другие отдают 500 со stack trace (хуже), третьи — структурированный JSON по стандарту.
В этом уроке разберём правильный дизайн ошибок: спецификацию RFC 7807 Problem Details, какой статус-код когда использовать (особенно 400 vs 422, 401 vs 403), как оформлять nested validation errors, что делать с локализацией и безопасностью. Если ты пишешь даже маленький internal API — это про твою боль через год.
Правило номер 1: статус-код должен соответствовать ситуации
# ПЛОХО -- преступление против HTTP
HTTP/1.1 200 OK
Content-Type: application/json
{"success": false, "error": "User not found"}
# ХОРОШО
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{"type": "...", "title": "User not found", "status": 404}
Почему плохой — целая инфраструктура построена на статус-кодах:
- Monitoring (Datadog, Grafana) считает rate of
5xxкак health metric. С200 OK + error in bodyтвои дашборды никогда не покажут проблем. - Retry-логика клиента опирается на
503,429,504. С200 + errorretry никогда не сработает. - Кэши кэшируют
200. С error в200ты будешь кэшировать ошибку. - Metric’и uptime, SLO/SLA — всё на статус-кодах.
Запомни: HTTP status — это первое, на что смотрит инфраструктура. Body — второе.
Какой статус-код когда
4xx — Client errors (виноват клиент)
| Код | Когда |
|---|---|
| 400 Bad Request | Запрос синтаксически невалиден: malformed JSON, отсутствуют required fields на уровне формата |
| 401 Unauthorized | Не аутентифицирован. Имя историческое — “не знаем кто ты”. Шли auth credentials |
| 403 Forbidden | Аутентифицирован, но не авторизован. “Знаем кто ты, но тебе нельзя” |
| 404 Not Found | Ресурс не существует |
| 405 Method Not Allowed | Resource есть, но метод не поддерживается. Например, PUT на /reports/today, который только GET |
| 406 Not Acceptable | Нет representation в формате, который клиент просит через Accept header |
| 409 Conflict | Конфликт состояния: дубликат email, уже забронировано, optimistic concurrency violation |
| 410 Gone | Ресурс существовал, но удалён навсегда (вместо 404 для deprecated endpoints) |
| 412 Precondition Failed | If-Match header не сошёлся (см. урок 2 про ETag) |
| 415 Unsupported Media Type | Content-Type клиента не поддерживается (например, прислал XML, ждали JSON) |
| 422 Unprocessable Entity | Запрос синтаксически валиден, но семантически нет. Email в правильном формате, но “не существует домена” |
| 429 Too Many Requests | Rate limit. Должен сопровождаться Retry-After header |
5xx — Server errors (виноват сервер)
| Код | Когда |
|---|---|
| 500 Internal Server Error | Generic “что-то сломалось”. Используй когда нет более специфичного |
| 502 Bad Gateway | Upstream вернул что-то странное. Часто за reverse proxy |
| 503 Service Unavailable | Временно недоступен (maintenance, перегружен). Должен сопровождаться Retry-After |
| 504 Gateway Timeout | Upstream не ответил вовремя |
400 vs 422 — частая путаница. Прагматично: 400 = “JSON битый, не смогли распарсить или required field отсутствует”. 422 = “JSON валидный, но email не email, или quantity отрицательное”. Спецификация HTTP это не строго разделяет, но индустрия (особенно validation libraries типа Django REST framework, FastAPI) использует именно так.
401 vs 403 — ещё одна путаница. 401 значит “пришли credentials, мы тебя не знаем” — ответ должен содержать WWW-Authenticate header, чтобы клиент понял какой auth scheme использовать. 403 значит “credentials есть, мы тебя знаем, но именно эту операцию нельзя”. Если у тебя нет токена и ты пытаешься зайти на защищённый endpoint — 401. Если ты залогинен как обычный пользователь и пытаешься удалить чужой пост — 403.
RFC 7807 Problem Details
Typed exceptions: try/except/else/finally + custom ExceptionВ 2016 году IETF выпустил RFC 7807 (обновлён до 9457 в 2023) — стандарт для error responses в HTTP API. Тип: application/problem+json. Базовая структура:
{
"type": "https://api.example.com/problems/insufficient-funds",
"title": "Insufficient funds",
"status": 403,
"detail": "Your account balance of 30.00 EUR is insufficient for this 50.00 EUR transaction",
"instance": "/transactions/abc-123-def"
}
Поля:
- type — URI идентифицирующий тип проблемы. Должен ссылаться на документацию (можно человекочитаемую). Это дискриминатор для клиента — по type он понимает “это insufficient-funds, а не invalid-card”.
- title — короткое человекочитаемое название типа. Не зависит от конкретного экземпляра.
- status — HTTP status code (дублирует header, для удобства).
- detail — конкретное описание этого экземпляра ошибки. Включает данные.
- instance — URI идентифицирующий этот экземпляр проблемы (для tracing, support).
Плюс extensions — любые дополнительные поля специфичные для типа:
{
"type": "https://api.example.com/problems/insufficient-funds",
"title": "Insufficient funds",
"status": 403,
"detail": "Your balance is insufficient",
"instance": "/transactions/abc-123-def",
"balance": 30.00,
"required": 50.00,
"currency": "EUR",
"deposit_url": "/account/deposit"
}
Зачем стандарт: клиенты могут писать переиспользуемый код обработки ошибок. Если каждый API возвращает свой формат — каждый клиент пишет свой парсер. С RFC 7807 — один парсер на все API.
Nested validation errors
Самый частый случай — валидация формы. Несколько полей могут быть невалидны одновременно. Соглашение через extension errors:
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/validation-failed",
"title": "Validation failed",
"status": 422,
"detail": "Request body has 2 invalid fields",
"instance": "/users",
"errors": [
{
"field": "email",
"code": "invalid_format",
"message": "Email must be a valid address"
},
{
"field": "age",
"code": "out_of_range",
"message": "Age must be between 13 and 120",
"min": 13,
"max": 120,
"actual": 200
}
]
}
Ключевые моменты:
field— путь к полю (для nested:address.zip, или JSON Pointer/address/zip).code— машино-читаемый код (invalid_format, неEmail is invalid— последнее уйдёт в локализацию).message— человекочитаемое (на одном языке, либо локализованное поAccept-Language).- Дополнительные поля в зависимости от типа кода.
import requests
r = requests.post(
"https://api.example.com/users",
json={"email": "not-email", "age": 200}
)
if r.status_code == 422:
problem = r.json()
print(f"{problem['title']}: {problem['detail']}")
for err in problem.get("errors", []):
print(f" field={err['field']} code={err['code']}: {err['message']}")
Безопасность: не leak-ать внутренности
Главное правило production: не возвращай детали внутренней реализации в error response. Это про:
Stack traces:
// ПЛОХО -- production
{
"error": "Internal server error",
"stack": "at db.query (postgres.py:42)\n at users.get (users.py:18)\n at app.handle (app.py:9)\n ...connection string: postgresql://admin:[email protected]:5432/users"
}
Stack trace раскрывает:
- Файловую структуру кода (можно искать известные exploits в фреймворке).
- Имена внутренних модулей.
- Иногда — connection strings с паролями (если включён в репрезентацию объекта).
SQL queries:
// ПЛОХО
{"error": "duplicate key violates unique constraint \"users_email_idx\" ON public.users"}
// ХОРОШО
{"type": ".../email-already-exists", "title": "Email already registered", "status": 409}
Стейтмент SQL раскрывает:
- Структуру БД (имена таблиц, индексов).
- Возможный SQL injection vector.
Detailed system errors:
// ПЛОХО
{"error": "Connection to redis://10.0.5.42:6379 timed out after 5000ms"}
// ХОРОШО
{"type": ".../service-temporarily-unavailable", "title": "Service unavailable", "status": 503}
Раскрывает internal IP-адреса, порты, имена сервисов.
В development окружении удобно видеть полные stack traces. В production это must-be-disabled by default. FastAPI, Django, Flask — у всех есть DEBUG flag. Никогда не выкатывай в prod с DEBUG=True. Это утечка по которой Equifax потерял данные 147 миллионов человек в 2017.
Правильная схема логирования
# Server-side логика
def handle_error(request, exc):
trace_id = request.headers.get("X-Trace-Id") or str(uuid.uuid4())
# Полный stack trace -- в логи (ELK, Datadog, Sentry)
logger.error(
f"trace_id={trace_id} unhandled exception",
exc_info=exc,
extra={"request_path": request.path, "user_id": request.user.id}
)
# Клиенту -- sanitized response с trace_id для support
return JsonResponse(
{
"type": "https://api.example.com/problems/internal-error",
"title": "Internal server error",
"status": 500,
"detail": "An unexpected error occurred. Please contact support with the trace_id.",
"instance": f"/errors/{trace_id}",
"trace_id": trace_id
},
status=500,
content_type="application/problem+json"
)
Клиент видит trace_id, может сообщить в support. Support видит в логах полную картину. Никаких утечек.
Локализация ошибок
Если API международный — Accept-Language header определяет язык сообщения:
$ curl -H "Accept-Language: ru" https://api.example.com/users -d '{"email": "bad"}'
{
"type": ".../validation-failed",
"title": "Ошибка валидации",
"status": 422,
"errors": [{"field": "email", "code": "invalid_format", "message": "Email должен быть валидным адресом"}]
}
$ curl -H "Accept-Language: en" https://api.example.com/users -d '{"email": "bad"}'
{
"type": ".../validation-failed",
"title": "Validation failed",
"status": 422,
"errors": [{"field": "email", "code": "invalid_format", "message": "Email must be a valid address"}]
}
Что локализуется: title, message в errors[], detail. Что НЕ локализуется: type (URI), code (машинный enum). Это критично — клиентский код опирается на code, не на message. Если завтра локализатор поменяет "Email is invalid" на "Bad email" — клиентский if-statement не должен сломаться.
Попробуй сам
import requests
# JSONPlaceholder -- fake API. Попробуй разные ошибки
r1 = requests.get("https://jsonplaceholder.typicode.com/users/9999")
print(r1.status_code, r1.json()) # 404 {}
# Github -- отличный пример хорошо структурированных ошибок
r2 = requests.get("https://api.github.com/users/torvalds/nonexistent-endpoint")
print(r2.status_code)
print(r2.json())
# {
# "message": "Not Found",
# "documentation_url": "https://docs.github.com/...",
# "status": "404"
# }
# Не RFC 7807 строго, но идея та же: машинный код, ссылка на доки
# Stripe -- почти RFC 7807 + extensions для каждого типа ошибки
# r3 = requests.post("https://api.stripe.com/v1/charges", auth=("sk_test_...", ""), data={"amount": "invalid"})
# 400 + {"error": {"type": "invalid_request_error", "code": "parameter_invalid_integer", "param": "amount", "message": "..."}}
Возьми свой любимый API. Сделай заведомо плохой запрос. Посмотри: какой status code, какая структура body, есть ли machine-readable code, есть ли trace_id, локализованы ли сообщения.
Killer takeaway
Status code — главное. 200 OK с error в body ломает всю инфраструктуру (monitoring, retry, cache). Используй 4xx для ошибок клиента (400 для синтаксиса, 422 для семантики, 401 vs 403 — auth vs authorization), 5xx для ошибок сервера. Структурируй ошибки по RFC 7807 Problem Details: type (URI типа), title, status, detail, instance + extensions. Для validation — массив errors[] с полями field, code (machine-readable), message (human-readable, локализуется). В production: never leak stack traces, SQL queries, internal IPs. Логируй полную картину с trace_id, клиенту возвращай sanitized response с тем же trace_id. Машино-читаемый code стабилен между локализациями — клиент опирается на него.