Опирается на правила: R-ERR-MAP-1R-ERR-MAP-5 и R-ERR-MAP-X1R-ERR-MAP-X3 из Error Handling Style Guide → раздел 3. Mapping в ProblemDetails.

Важно знать

  • DomainError409 (нарушение текущего состояния) или 422 (нарушение бизнес-инвариантов).
  • InputValidationError400. Pydantic RequestValidationError приводить к той же форме с errors-массивом per-field.
  • IntegrationError502 (внешка 5xx), 503 (CB открыт / bulkhead reject), 504 (timeout).
  • TechnicalError500. Минимум в response — только «Internal Server Error» + traceId.
  • Catch-all Exception500. 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": "..."}) с 200R-ERR-MAP-X1problem(422, ..., ...) с правильным статусом
"detail": traceback.format_exc() в ответеR-ERR-MAP-X2Stacktrace только в логи, 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 — нормативные формулировки.