← назад к разделу

PYTHON · REST API Фаза 14. API: REST и контракты

Обязательно

Ошибки REST API: RFC 9457 Problem Details

problem+json · Violations · Handlers

Машиночитаемый код, человекочитаемое объяснение и список всех нарушений в одном предсказуемом ответе.

1. Problem Details - единый конверт ошибки

1 / 5

type идентифицирует проблему, title и detail объясняют её, status дублирует HTTP, code стабилен для программной обработки.

application/problem+jsontypeurn:problem:...title + detailчеловекуstatus + codeклиентуinstance связывает конкретный случай

2. Обработчики заменяют формат FastAPI

2 / 5

RequestValidationError превращается в единый Problem Details, доменные исключения получают свой mapping. Внутренний 500 не раскрывает стек и SQL.

ValidationErrorOrderNotFoundexception_handlermap to problem400 / 404same shape

3. Валидация собирает все violations

3 / 5

Клиент узнаёт обо всех неправильных полях за один запрос: путь, машинный код и сообщение. Валидация входа - 400 в выбранном контракте, а не смешанная с доменной 422.

code: VALIDATION_ERRORfield: pricemust be greater than 0field: namemust not be blankviolations: [field, code, message]

4. HTTP-код выбирается по причине

4 / 5

400 - неверный запрос, 401 - личность не подтверждена, 403 - права недостаточно, 404 - ресурс не найден, 409 - конфликт состояния, 429 - слишком много запросов.

400bad input401 / 403identity / permission404not found409 Conflictversion / state429 Too Many RequestsRetry-After

5. Чужой ресурс не раскрывается

5 / 5

В ряде API 404 вместо 403 скрывает сам факт существования записи. 401 содержит WWW-Authenticate, 500 - нейтральный code без внутренних деталей.

Чужой order 42не раскрывать существование404 Not FoundUnexpected errorстек и SQL только в лог500 INTERNAL_ERROR

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

RFC 9457 решает эту проблему: стандарт описывает единый формат тела ошибки под названием Problem Details. FastAPI из коробки его не использует — придётся настроить.

Что такое Problem Details

Problem Details — это JSON-объект с конкретным набором полей:

{
  "type": "urn:problem:order-service:order-not-found",
  "status": 404,
  "title": "Not Found",
  "detail": "Заказ a1b2c3 не найден",
  "instance": "urn:uuid:9f2d6c22-...",
  "traceId": "00-1f2a8b6c...",
  "code": "ORDER_NOT_FOUND"
}
  • type — стабильный идентификатор категории ошибки. Обычно URN вида urn:problem:<сервис>:<код> или URL на страницу документации. Один и тот же тип ошибки всегда имеет один и тот же type.
  • status — HTTP-код (дублируется в теле для удобства парсинга).
  • title — короткое человекочитаемое название, соответствующее HTTP-коду.
  • detail — конкретное описание этой конкретной ошибки.
  • code — машиночитаемый код в UPPER_SNAKE_CASE для программной логики на клиенте.
  • traceId — идентификатор запроса для поиска в логах.

Ответы с ошибками отдаются с заголовком Content-Type: application/problem+json, а не обычным application/json.

Почему FastAPI нужно перенастраивать

FastAPI по умолчанию возвращает 422 Unprocessable Entity при ошибке валидации Pydantic. Тело при этом выглядит так:

{
  "detail": [
    {
      "type": "missing",
      "loc": ["body", "amount"],
      "msg": "Field required",
      "input": {}
    }
  ]
}

Проблемы этого формата:

  • Код 422 нестандартен для валидации — правильный код 400 Bad Request.
  • Структура не совпадает с RFC 9457 — клиент не может обрабатывать ошибки единообразно.
  • Формат привязан к Pydantic: при смене мажорной версии библиотеки он уже менялся (value_error.missing в первой версии против missing во второй), а клиент не должен зависеть от библиотеки на сервере.

Решение — переопределить оба обработчика исключений FastAPI.

Модели данных

Сначала опишем структуры через Pydantic:

from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel

class Violation(BaseModel):
    field: str | None = None
    message: str

class ProblemDetail(BaseModel):
    type: str
    status: int
    title: str
    detail: str
    instance: str | None = None
    trace_id: str | None = None
    code: str
    violations: list[Violation] | None = None

    model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)

alias_generator=to_camel автоматически переводит trace_id → traceId в JSON.

Обработчик ошибок валидации (422 → 400)

FastAPI бросает RequestValidationError когда тело запроса не соответствует схеме Pydantic. Переопределяем обработчик:

from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import Response
import json

app = FastAPI()

def _map_loc(loc: tuple) -> str:
    parts = [str(p) for p in loc if p != "body"]
    return ".".join(parts)

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
    request: Request, exc: RequestValidationError
) -> Response:
    violations = [
        {"field": _map_loc(e["loc"]), "message": e["msg"]}
        for e in exc.errors()
    ]
    body = {
        "type": "urn:problem:order-service:validation-error",
        "status": 400,
        "title": "Bad Request",
        "detail": "Ошибка валидации входных данных",
        "code": "VALIDATION_ERROR",
        "traceId": getattr(request.state, "trace_id", None),
        "violations": violations,
    }
    body = {k: v for k, v in body.items() if v is not None}
    return Response(
        content=json.dumps(body, ensure_ascii=False),
        status_code=400,
        media_type="application/problem+json",
    )

Ключевые моменты:

  • Возвращаем ответ с media_type="application/problem+json": подойдёт и JSONResponse(content=body, status_code=400, media_type="application/problem+json"), здесь Response взят, чтобы управлять ensure_ascii=False и не экранировать кириллицу.
  • Собираем все нарушения из exc.errors() — не только первое.
  • Функция _map_loc убирает лишний префикс "body" и превращает путь в dot-notation.

Доменные исключения

Для ошибок бизнес-логики создаём базовый класс и конкретные исключения:

репозиторий: заказ не найден доменное исключение, без HTTP обработчик: raise OrderNotFound FastAPI ищет обработчик по __mro__ реестр exception_handler по типу type, status, title, detail, code ответ 404 application/problem+json

Исключение рождается в домене без знания о HTTP, а в статус и тело Problem Details его переводит один обработчик на тип.

from enum import StrEnum

class ErrorCode(StrEnum):
    ORDER_NOT_FOUND = "ORDER_NOT_FOUND"
    ORDER_EMPTY = "ORDER_EMPTY"
    INSUFFICIENT_STOCK = "INSUFFICIENT_STOCK"
    RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED"
    VALIDATION_ERROR = "VALIDATION_ERROR"
    INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR"

class DomainError(Exception):
    def __init__(self, code: ErrorCode, detail: str, status: int = 400):
        self.code = code
        self.detail = detail
        self.status = status

class OrderNotFoundError(DomainError):
    def __init__(self, order_id: str):
        super().__init__(
            code=ErrorCode.ORDER_NOT_FOUND,
            detail=f"Заказ {order_id} не найден",
            status=404,
        )

И обработчик для них:

import uuid

HTTP_STATUS_TITLES = {
    400: "Bad Request",
    401: "Unauthorized",
    403: "Forbidden",
    404: "Not Found",
    409: "Conflict",
    410: "Gone",
    429: "Too Many Requests",
    500: "Internal Server Error",
}

@app.exception_handler(DomainError)
async def domain_error_handler(request: Request, exc: DomainError) -> Response:
    body = {
        "type": f"urn:problem:order-service:{exc.code.lower().replace('_', '-')}",
        "status": exc.status,
        "title": HTTP_STATUS_TITLES.get(exc.status, "Error"),
        "detail": exc.detail,
        "instance": f"urn:uuid:{uuid.uuid4()}",
        "code": exc.code,
        "traceId": getattr(request.state, "trace_id", None),
    }
    body = {k: v for k, v in body.items() if v is not None}
    return Response(
        content=json.dumps(body, ensure_ascii=False),
        status_code=exc.status,
        media_type="application/problem+json",
    )

Обработчик непредвиденных ошибок

Для всего что не поймали выше — отдельный обработчик:

@app.exception_handler(Exception)
async def generic_error_handler(request: Request, exc: Exception) -> Response:
    body = {
        "type": "urn:problem:order-service:internal-error",
        "status": 500,
        "title": "Internal Server Error",
        "detail": "Внутренняя ошибка сервера",
        "code": "INTERNAL_SERVER_ERROR",
        "traceId": getattr(request.state, "trace_id", None),
    }
    body = {k: v for k, v in body.items() if v is not None}
    return Response(
        content=json.dumps(body, ensure_ascii=False),
        status_code=500,
        media_type="application/problem+json",
    )

Важно: в тело 500 никогда не включают стек вызовов, SQL-запросы или внутренние пути файловой системы. Только traceId — по нему можно найти детали в логах. Это защита от случайной утечки деталей реализации клиенту.

Поле violations — список нарушений валидации

Когда в запросе несколько ошибок, отдаём их все за один ответ. Путь к полю передаём в dot-notation:

def _map_loc(loc: tuple) -> str:
    parts = []
    for p in loc:
        if p == "body":
            continue
        parts.append(str(p))
    return ".".join(parts)

Пример маппинга путей Pydantic:

Путь от PydanticПоле в ответе
("body", "delivery_address", "zip_code")deliveryAddress.zipCode
("body", "items", 0, "quantity")items.0.quantity

Итоговый JSON:

{
  "violations": [
    { "field": "amount", "message": "Сумма должна быть больше 0" },
    { "field": "deliveryAddress.zipCode", "message": "Почтовый индекс обязателен" },
    { "field": "items.0.quantity", "message": "Количество от 1 до 99" }
  ]
}

Какие HTTP-коды использовать

КодКогда использовать
400 Bad Requestнекорректный формат тела, ошибки валидации
401 Unauthorizedнет токена или токен истёк
403 Forbiddenтокен есть, но прав недостаточно
404 Not Foundобъект по ID не найден
409 Conflictконфликт при одновременном изменении, дубликат
410 Goneэндпоинт удалён и больше не существует
429 Too Many Requestsпревышен лимит запросов
500 Internal Server Errorнепредвиденная ошибка сервера

Код 422 не используется для валидации запросов — только 400. Нестандартные коды (418, 451) тоже не используются: клиенты их не знают и не обрабатывают.

Частые ошибки

Content-Type: application/json вместо application/problem+json. Ошибки — особый тип ответа и требуют отдельного медиатипа. Клиент может автоматически направлять ответы с problem+json в обработчик ошибок.

type: "about:blank". Это специальное значение RFC означает «тип не указан». Клиент не может программно различить категории ошибок. Всегда указывай конкретный URN или URL.

Только первая ошибка валидации. Пользователь исправит одно поле, отправит запрос снова и получит следующую ошибку. Лучше вернуть все нарушения сразу через violations.

Стек вызовов в detail ошибки 500. Это раскрывает внутреннюю структуру приложения. Логируй детали на сервере, клиенту — только traceId.

Дополнительно: при первом чтении можно пропустить

Глубже: 401, 403 и 404: кто вы, что вам можно и чего вы не должны узнатьрасширенное

Три кода из таблицы выше путают чаще остальных, а разница между ними это разница между «исправь запрос» и «утечка данных».

401 Unauthorized вопреки названию не про права, а про личность: сервер не знает, кто перед ним. Токена нет, он истёк, подпись не сошлась. Ответ обязан нести WWW-Authenticate: Bearer, по нему клиент понимает, что надо обновить токен и повторить. В FastAPI его бросает зависимость principal: HTTPException(401, headers={"WWW-Authenticate": "Bearer"}).

403 Forbidden означает «мы знаем, кто вы, и вам нельзя». Обновлять токен бессмысленно, и клиент показывает «доступ запрещён». Его бросает require_roles или проверка владения.

404 Not Found для чужого ресурса это осознанная политика, а не ошибка. Если покупатель запрашивает /orders/{id} чужого заказа и получает 403, он узнал, что заказ с таким номером существует; для перебора идентификаторов этого достаточно. Поэтому там, где сам факт существования секрет, чужой ресурс отвечает 404, тем же телом и за то же время, что и несуществующий. Политику выбирают на ресурс целиком: заказы покупателей скрывают, товары каталога нет, и она записана в описании API, иначе один обработчик отдаст 403, другой 404, и утечка вернётся.

Как это ложится на цепочку зависимостей: principal отвечает за 401, require_roles за 403 по роли, AccessPolicy или обработчик за 403/404 по владению, о чём статьи где делать проверку и владение ресурсом. В формате Problem Details у всех трёх свой type и code, а detail для 404 одинаков для чужого и несуществующего.

Коротко

  • RFC 9457 Problem Details — стандартный формат тела ошибки: type, status, title, detail, code, опционально violations.
  • Ответы с ошибками возвращаются с Content-Type: application/problem+json.
  • FastAPI по умолчанию возвращает 422 на ошибку валидации — нужно переопределить обработчик на 400.
  • type — стабильный URN или URL, всегда конкретный, не about:blank.
  • code — UPPER_SNAKE_CASE из StrEnum, нужен клиенту для программной логики.
  • В violations возвращаем все нарушения сразу, путь к полю — dot-notation.
  • В тело 500 не включаем стек вызовов и SQL — только traceId.
  • 401 — не знаем, кто вы (с WWW-Authenticate), 403 — знаем, но нельзя, 404 — чужой ресурс, о существовании которого клиенту знать не положено; политику выбирают на ресурс, а не на эндпоинт.

Что пощупать

Problem Details в практикуме remodov/marketplace-system-python собирает одна функция problem: type, title, status, detail, instance и необязательная карта errors. Тесты шага про команды смотрят в errors.price и errors.delta и различают 400 «запрос нельзя начать» от 409 «состояние не позволяет».

Код: problem.py, test_change_api.py.

Сделаем сами

Ветка step-03-commands-and-errors.

Что почитать дальше