Когда 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, а в статус и тело 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.
Что почитать дальше
- JSON и формат ответов в FastAPI — структура успешных ответов.
- Заголовки и трассировка в FastAPI — как
traceparentпревращается вtraceId. - Rate limiting, файлы, deprecation — коды 429 и 410 на практике.
- Модель ошибок — как устроена иерархия ошибок сервиса до того, как они становятся HTTP-ответами.