Learning Platform
Глоссарий Troubleshooting
Урок 06.04 · 25 мин
Начальный
errorsrfc-7807problem-detailsvalidationsecuritystatus-codes

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 + error retry никогда не сработает.
  • Кэши кэшируют 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 AllowedResource есть, но метод не поддерживается. Например, PUT на /reports/today, который только GET
406 Not AcceptableНет representation в формате, который клиент просит через Accept header
409 ConflictКонфликт состояния: дубликат email, уже забронировано, optimistic concurrency violation
410 GoneРесурс существовал, но удалён навсегда (вместо 404 для deprecated endpoints)
412 Precondition FailedIf-Match header не сошёлся (см. урок 2 про ETag)
415 Unsupported Media TypeContent-Type клиента не поддерживается (например, прислал XML, ждали JSON)
422 Unprocessable EntityЗапрос синтаксически валиден, но семантически нет. Email в правильном формате, но “не существует домена”
429 Too Many RequestsRate limit. Должен сопровождаться Retry-After header

5xx — Server errors (виноват сервер)

КодКогда
500 Internal Server ErrorGeneric “что-то сломалось”. Используй когда нет более специфичного
502 Bad GatewayUpstream вернул что-то странное. Часто за reverse proxy
503 Service UnavailableВременно недоступен (maintenance, перегружен). Должен сопровождаться Retry-After
504 Gateway TimeoutUpstream не ответил вовремя
WARNING

400 vs 422 — частая путаница. Прагматично: 400 = “JSON битый, не смогли распарсить или required field отсутствует”. 422 = “JSON валидный, но email не email, или quantity отрицательное”. Спецификация HTTP это не строго разделяет, но индустрия (особенно validation libraries типа Django REST framework, FastAPI) использует именно так.

WARNING

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"
}
Структура Problem Details: фиксированные поля и extensions
application/problem+json -- официальный media type для RFC 7807
Стандартные поля по RFC 7807
type (URI)URI типа проблемы -- для машинной классификации
titleКороткий human-readable заголовок типа
statusHTTP status code (дублирует header)
detailКонкретное описание этого экземпляра
instanceURI этого экземпляра -- для трассировки
Любые дополнительные поля специфичные для типа
errors[]Например, для validation errors
retry_afterДля retry-able ошибок: когда снова можно
limitДля rate limit: текущий лимит
trace_idTrace ID для поддержки

Зачем стандарт: клиенты могут писать переиспользуемый код обработки ошибок. Если каждый 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-адреса, порты, имена сервисов.

DANGER

В 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 стабилен между локализациями — клиент опирается на него.

Проверка знанийKnowledge check
ОтветAnswer

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. Почему возвращать 200 OK с {error: ...} в body -- это плохой дизайн?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4