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

Когда 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": [
    {
      "loc": ["body", "amount"],
      "msg": "field required",
      "type": "value_error.missing"
    }
  ]
}

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

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

Решение — переопределить оба обработчика исключений 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_idtraceId в 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",
    )

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

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

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

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

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:

("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.

Коротко

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

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

  • JSON и формат ответов в FastAPI — структура успешных ответов.
  • Заголовки и трассировка в FastAPI — как traceparent превращается в traceId.
  • Rate limiting, файлы, deprecation — коды 429 и 410 на практике.
  • Ошибки REST API — формат Problem Details — язык-нейтральный обзор формата.