Когда 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_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",
)
Ключевые моменты:
- Возвращаем
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.code—UPPER_SNAKE_CASEизStrEnum, нужен клиенту для программной логики.- В
violationsвозвращаем все нарушения сразу, путь к полю — dot-notation. - В тело
500не включаем стек вызовов и SQL — толькоtraceId.
Что почитать дальше
- JSON и формат ответов в FastAPI — структура успешных ответов.
- Заголовки и трассировка в FastAPI — как
traceparentпревращается вtraceId. - Rate limiting, файлы, deprecation — коды 429 и 410 на практике.
- Ошибки REST API — формат Problem Details — язык-нейтральный обзор формата.