Опирается на правила:
R-ERR-MAP-1…R-ERR-MAP-5иR-ERR-MAP-X1…R-ERR-MAP-X3из Error Handling Style Guide → раздел 3. Mapping в ProblemDetails.
Важно знать
DomainError→ 409 (нарушение текущего состояния) или 422 (нарушение бизнес-инвариантов).InputValidationError→ 400. PydanticRequestValidationErrorприводить к той же форме сerrors-массивом per-field.IntegrationError→ 502 (внешка 5xx), 503 (CB открыт / bulkhead reject), 504 (timeout).TechnicalError→ 500. Минимум в response — только «Internal Server Error» +traceId.- Catch-all
Exception→ 500. ERROR-лог + полный stacktrace + контекст — сигнал бага.typeв ProblemDetails — URL на код ошибки в каталоге (https://api.example.com/errors/insufficient-funds).- Запрещено: HTTP 200 с
{"success": false}; stacktrace вdetail; сыройstr(exc)без санитизации.
RFC 9457 — стандарт machine-readable HTTP-ответа об ошибке. FastAPI не даёт встроенного ProblemDetails (в отличие от Spring Boot 3), поэтому реализуем через JSONResponse с media_type="application/problem+json". Цель — единый формат: клиент видит status, type, detail, extension-поля — может реагировать программно, а не парсить сообщение строкой. Раскрытие правил R-ERR-MAP-* ниже.
Хелпер problem()
Единая точка формирования ответа — один хелпер на весь сервис:
# app/problem.py
from fastapi.responses import JSONResponse
def problem(
status: int,
title: str,
detail: str,
*,
type_: str = "about:blank",
trace_id: str | None = None,
**ext: object,
) -> JSONResponse:
body: dict[str, object] = {
"type": type_,
"title": title,
"status": status,
"detail": detail,
}
if trace_id:
body["traceId"] = trace_id
body.update(ext)
return JSONResponse(
status_code=status,
content=body,
media_type="application/problem+json",
)
Почему **ext, а не фиксированная схема — extension-поля по RFC 9457 произвольны; каждый handler добавляет нужный контекст: customerId, requested, available для InsufficientFundsError, errors для валидации.
DomainError → 409 / 422
R-ERR-MAP-1: бизнес-правило нарушено — Conflict или Unprocessable Entity.
409 Conflict — операция конфликтует с текущим состоянием ресурса:
- Нельзя отменить отгруженный заказ (
OrderAlreadyShippedError). - Email уже используется (
EmailAlreadyTakenError). - Заказ уже подтверждён (
OrderAlreadyConfirmedError).
422 Unprocessable Entity — нарушен бизнес-инвариант:
- Недостаточно средств (
InsufficientFundsError). - Сумма ниже минимальной (
OrderTotalBelowMinimumError). - Клиент не подходит под условия (
CustomerNotEligibleError).
# app/error_handlers.py
async def _handle_insufficient_funds(
request: Request, exc: InsufficientFundsError
) -> JSONResponse:
logger.warning("domain rule violated", error=str(exc))
app_errors_total.labels(type="domain", exception="InsufficientFundsError").inc()
return problem(
422,
"Operation cannot be completed",
"insufficient funds",
type_="https://api.example.com/errors/insufficient-funds",
trace_id=get_trace_id(),
customerId=exc.customer_id,
requested=str(exc.requested),
available=str(exc.available),
)
async def _handle_order_already_shipped(
request: Request, exc: OrderAlreadyShippedError
) -> JSONResponse:
logger.warning("domain rule violated", error=str(exc))
app_errors_total.labels(type="domain", exception="OrderAlreadyShippedError").inc()
return problem(
409,
"Conflict",
"order is already shipped",
type_="https://api.example.com/errors/order-already-shipped",
trace_id=get_trace_id(),
orderId=exc.order_id,
)
Пример ответа клиенту:
{
"type": "https://api.example.com/errors/insufficient-funds",
"title": "Operation cannot be completed",
"status": 422,
"detail": "insufficient funds",
"traceId": "abc-123",
"customerId": "cust-42",
"requested": "5000.00",
"available": "1200.50"
}
Extension-поля (customerId, requested, available) — структурированный контекст. Клиент достаёт их программно и показывает в UI.
InputValidationError → 400
R-ERR-MAP-2: невалидный вход. В errors-массиве per-field подробности.
Два источника 400 в FastAPI:
a) Pydantic RequestValidationError — автоматически при невалидном request body:
async def _handle_pydantic(request: Request, exc: RequestValidationError) -> JSONResponse:
errors = [
{"field": ".".join(map(str, e["loc"])), "message": e["msg"]}
for e in exc.errors()
]
app_errors_total.labels(type="validation", exception="RequestValidationError").inc()
return problem(
400,
"Validation failed",
"request body is invalid",
type_="https://api.example.com/errors/validation",
trace_id=get_trace_id(),
errors=errors,
)
b) Собственный InputValidationError — когда валидация требует бизнес-контекста, недоступного в Pydantic-схеме:
# core/use_cases/create_order.py
class CreateOrderHandler:
async def handle(self, cmd: CreateOrderCommand) -> Order:
if not await self._customers.is_active(cmd.customer_id):
raise InputValidationError(
f"Customer {cmd.customer_id} is not active"
)
...
Пример ответа:
{
"type": "https://api.example.com/errors/validation",
"title": "Validation failed",
"status": 400,
"detail": "request body is invalid",
"traceId": "def-456",
"errors": [
{"field": "customerId", "message": "field required"},
{"field": "amount", "message": "value must be greater than 0"}
]
}
Поле loc у Pydantic — это путь в JSON: ("body", "customerId") → "body.customerId". Для вложенных структур: ("body", "address", "city") → "body.address.city".
IntegrationError → 502 / 503 / 504
R-ERR-MAP-3: разная семантика по типу проблемы. Сырое тело внешки в detail не вкладываем (PII).
502 Bad Gateway — внешка вернула 5xx:
async def _handle_integration(request: Request, exc: IntegrationError) -> JSONResponse:
logger.warning("integration error", exc_type=type(exc).__name__, error=str(exc))
app_errors_total.labels(type="integration", exception=type(exc).__name__).inc()
return problem(
502,
"External system temporarily unavailable",
"upstream error",
trace_id=get_trace_id(),
)
503 Service Unavailable — CB открыт или bulkhead reject (выделяем в отдельный тип):
# adapters/out/sber/errors.py
class SberGatewayUnavailableError(SberGatewayError): ... # бросается из fallback CB
# app/error_handlers.py
async def _handle_sber_unavailable(
request: Request, exc: SberGatewayUnavailableError
) -> JSONResponse:
logger.error("circuit breaker open for sber", exc_type="SberGatewayUnavailableError")
app_errors_total.labels(type="integration", exception="SberGatewayUnavailableError").inc()
return problem(
503,
"Payment provider temporarily unavailable",
"try again later",
trace_id=get_trace_id(),
)
504 Gateway Timeout — по отдельному типу или по детекции причины:
class SberGatewayTimeoutError(SberGatewayError): ...
async def _handle_sber_timeout(
request: Request, exc: SberGatewayTimeoutError
) -> JSONResponse:
logger.warning("sber timeout", exc_type="SberGatewayTimeoutError")
app_errors_total.labels(type="integration", exception="SberGatewayTimeoutError").inc()
return problem(504, "Gateway Timeout", "payment provider did not respond in time",
trace_id=get_trace_id())
Почему нельзя вкладывать сырое тело внешки в detail:
# ПЛОХО
return problem(502, "...", f"sber returned: {exc.upstream_body}")
Тело внешней системы может содержать PII (email, имя), внутренние структуры, debug-данные. Это утечка. Общая фраза + traceId для cross-system корреляции — этого достаточно.
TechnicalError → 500
R-ERR-MAP-4: минимум в response, детали в логи.
async def _handle_technical(request: Request, exc: TechnicalError) -> JSONResponse:
logger.error("technical error", exc_info=exc)
app_errors_total.labels(type="technical", exception=type(exc).__name__).inc()
return problem(
500,
"Internal Server Error",
"internal error",
trace_id=get_trace_id(),
)
В response только status: 500, detail: "internal error", traceId. Никакого str(exc) в detail — там может оказаться строка соединения к БД, путь к файлу, что угодно (AUTH-18). По traceId оператор найдёт детали в логах.
Catch-all → 500
R-ERR-MAP-5: Exception — последний бастион.
async def _handle_unexpected(request: Request, exc: Exception) -> JSONResponse:
logger.error(
"unexpected error — not in hierarchy",
exc_info=exc,
exc_type=type(exc).__name__,
)
app_errors_total.labels(type="unexpected", exception=type(exc).__name__).inc()
return problem(
500,
"Internal Server Error",
"internal error",
trace_id=get_trace_id(),
)
Попало сюда — сигнал бага: появился тип исключения вне иерархии. unexpected-counter растёт → создавать задачу → добавить тип в иерархию → написать тест. Детали — Observability ошибок.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
return JSONResponse({"success": False, "error": "..."}) с 200 | R-ERR-MAP-X1 | problem(422, ..., ...) с правильным статусом |
"detail": traceback.format_exc() в ответе | R-ERR-MAP-X2 | Stacktrace только в логи, traceId в response |
"detail": str(exc) для низкоуровневой ошибки без санитизации | R-ERR-MAP-X3 | Наша доменная фраза или "internal error" |
| Разные форматы ответа об ошибке в разных роутерах | — | Один register_error_handlers для всего app |
raise HTTPException(status_code=500, detail=str(e)) | R-ERR-MAP-X3 | Кидать типизированный наследник, handler сформирует ответ |
R-ERR-MAP-X1 — почему HTTP 200 при ошибке критично: мониторинг (Sentry, Grafana) считает всё, что не 4xx/5xx, успехом. Алёрты не сработают на {"success": false, "error": "insufficient funds"} в теле 200-ответа. REST-семантика нарушена: клиенту приходится парсить тело, чтобы узнать «успех или нет».
R-ERR-MAP-X3 — пример опасного str(exc) без санитизации: если exc — это sqlalchemy.exc.OperationalError, его строковое представление: "(psycopg2.errors.UndefinedTable) ERROR: relation "order_doc" does not exist\nLINE 1: SELECT...". Это раскрытие схемы БД клиенту.
Использовать raise HTTPException из FastAPI — допустимо только в крайне простых сценариях без типизированной иерархии. В UCP-сервисе с register_error_handlers — FastAPI обрабатывает HTTPException через собственный дефолтный handler до catch-all Exception; переопределить можно через app.add_exception_handler(HTTPException, ...), но в сервисе с типизированной иерархией достаточно типизированных подклассов — HTTPException использовать не нужно.
Куда дальше
- Иерархия исключений — типы, которые мы мапим.
- Где raise, где except — где живёт
register_error_handlers. - Логирование исключений — log-level в каждом handler.
- Retry-семантика — как 4xx vs 5xx влияет на retry.
- Observability ошибок — метрики
app_errors_total. - Result types vs exceptions — когда
Resultдопустим. - Error Handling Style Guide → раздел 3 — нормативные формулировки.