Опирается на правила:
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 документации.code—UPPER_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 Forbidden | RBAC/ABAC отказал |
404 Not Found | объект по ID не найден |
409 Conflict | concurrent modification, duplicate |
410 Gone | deprecated endpoint удалён |
429 Too Many Requests | rate limit |
500 Internal Server Error | неожиданные исключения |
R-ERR-X3: HTTP-коды вне списка (418, 422, 451) — запрещены.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Content-Type: application/json для ошибки | R-ERR-X1 | application/problem+json |
type: "about:blank" | R-ERR-X2 | urn:problem:<service>:<code> |
| HTTP 422 на валидацию | R-ERR-X3 | переопредели handler → 400 |
Stack trace в detail | R-ERR-X4 | traceId для cross-ref |
SQL-запрос в теле 500 | R-ERR-X4 | generic message |
| Дефолтный FastAPI validation handler | R-ERR-5 | переопредели оба handler |
| Только первая ошибка валидации | R-ERR-6 | все violations за один запрос |
Куда дальше
- REST API → Ошибки (нормативно) — формулировки.
- Заголовки и трассировка —
traceparent→traceId. - Rate limiting, файлы, deprecation — 429, 410.
- JSON и формат ответов — success format vs error.
- Batch, async, локализация —
Accept-Language→detail.