Опирается на правила: R-ERR-1..9 и R-ERR-X1..X4 из REST API Style Guide → раздел Ошибки RFC 9457.

Важно знать

  • Тело ошибки — RFC 9457 Problem Details.
  • Content-Type: application/problem+json (не application/json).
  • Дефолтный FastAPI отдаёт 422 на ошибку валидации — переопредели handler на 400.
  • type — стабильный urn:problem:<service>:<code> или URL документации.
  • codeUPPER_SNAKE_CASE из StrEnum для программной логики на клиенте.
  • traceId — из traceparent (W3C) через middleware или request state.
  • violations для валидации: маппинг из RequestValidationError.errors().
  • HTTP 422 — запрещён (R-ERR-X3). Только стандартные коды.

FastAPI по умолчанию возвращает 422 Unprocessable Entity при ошибке валидации Pydantic с нестандартным форматом. Нужно переопределить оба exception handler.

Структура Problem Details

R-ERR-1:

from pydantic import BaseModel

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)
{
  "type": "urn:problem:order-service:validation-error",
  "status": 400,
  "title": "Validation Error",
  "detail": "Ошибка валидации входных данных",
  "instance": "urn:uuid:9f2d6c22-...",
  "traceId": "00-1f2a8b6c...",
  "code": "VALIDATION_ERROR",
  "violations": [
    { "field": "amount", "message": "Сумма должна быть больше 0" }
  ]
}

Exception handlers

Переопределение RequestValidationError (422 → 400)

R-ERR-5, R-ERR-X3:

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": request.state.trace_id if hasattr(request.state, "trace_id") else 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",
    )

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

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",
    )

R-ERR-X4: stack traces, SQL-запросы, внутренние пути — не включаем в тело 500.

type — стабильный URI/URN

R-ERR-2: одна категория — всегда один type.

Две формы:

# URL на резолвимую страницу документации
"type": "https://errors.example.com/order/not-found"

# URN (если портала нет)
"type": "urn:problem:order-service:order-not-found"
"type": "urn:problem:catalog:product-archived"

R-ERR-X2: type: "about:blank" — запрещено. Теряется машиночитаемая категория.

Violations — маппинг из Pydantic

R-ERR-6: dot-notation для вложенных, индексы для массивов.

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

Из RequestValidationError:

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

HTTP-коды

R-ERR-9:

КодКогда
400 Bad Requestвалидация, malformed body
401 Unauthorizedнет токена, истёк
403 ForbiddenRBAC/ABAC отказал
404 Not Foundобъект по ID не найден
409 Conflictconcurrent modification, duplicate
410 Gonedeprecated endpoint удалён
429 Too Many Requestsrate limit
500 Internal Server Errorнеожиданные исключения

R-ERR-X3: HTTP-коды вне списка (418, 422, 451) — запрещены.

Что запрещено

АнтипаттернПравилоЧто взамен
Content-Type: application/json для ошибкиR-ERR-X1application/problem+json
type: "about:blank"R-ERR-X2urn:problem:<service>:<code>
HTTP 422 на валидациюR-ERR-X3переопредели handler → 400
Stack trace в detailR-ERR-X4traceId для cross-ref
SQL-запрос в теле 500R-ERR-X4generic message
Дефолтный FastAPI validation handlerR-ERR-5переопредели оба handler
Только первая ошибка валидацииR-ERR-6все violations за один запрос

Куда дальше

  • REST API → Ошибки (нормативно) — формулировки.
  • Заголовки и трассировка — traceparenttraceId.
  • Rate limiting, файлы, deprecation — 429, 410.
  • JSON и формат ответов — success format vs error.
  • Batch, async, локализация — Accept-Languagedetail.