Опирается на правила: R-ERR-HIER-1R-ERR-HIER-5 и R-ERR-HIER-X1R-ERR-HIER-X2 из Error Handling Style Guide → раздел 1. Иерархия исключений.

Важно знать

  • Четыре базовых типа в проекте: DomainError, InputValidationError, IntegrationError, TechnicalError. Всё остальное — наследники.
  • Все четыре наследуют AppError, а не «голый» Exception. В Python нет checked exceptions, поэтому контракт держится типизированной иерархией и edge-handler'ом.
  • Имена доменных исключений — по бизнес-смыслу: OrderAlreadyShippedError, InsufficientFundsError. Не BusinessError, не ValueError.
  • IntegrationError-наследники с префиксом системы: PaymentGatewayError, CatalogPortError. Edge-handler различает «у платёжки» vs «у каталога».
  • Конструктор фиксирует контекст: InsufficientFundsError(customer_id=..., requested=..., available=...). Идиоматично — @dataclass-исключение.
  • raise Exception("...") запрещён — тип теряется, edge-handler не отличит от технической проблемы.
  • raise AssertionError / assert в доменном коде как бизнес-правило — запрещён. Бизнес-правило → DomainError; нарушение инварианта агрегата → ловит unit-тест, не endpoint.

Иерархия исключений — это контракт обработки, а не вопрос удобства. Каждое исключение имеет тип, документированный смысл и однозначный handler на edge. Без типизированной иерархии все ошибки сливаются в Exception → 500 → logger.exception("failed") — никакой осмысленной реакции. Раскрытие правил R-ERR-HIER-* ниже.

Четыре типа

R-ERR-HIER-1: всё разнообразие ошибок FastAPI-сервиса делится на четыре непересекающиеся категории.

ТипКогда бросаетсяHTTP-статусRetry-safe
DomainErrorНарушено бизнес-правило (нельзя отменить отгруженный заказ, недостаточно средств)409 / 422❌ Нет
InputValidationErrorНевалидный вход на edge (поле пустое, формат не тот)400❌ Нет
IntegrationErrorВнешняя система отвечает неожиданно (5xx, timeout, malformed)502 / 503 / 504✅ Обычно (при идемпотентности)
TechnicalErrorНаша внутренняя проблема (БД unreachable, misconfiguration)500✅ Возможно

Где они живут физически в UCP-структуре:

  • DomainError и наследники — в core/errors.py. Это часть domain — бизнес-правила формулируются здесь.
  • InputValidationError — в edge (app/). Это про невалидный HTTP-вход, не про domain. Pydantic-ошибки (RequestValidationError) — отдельный handler, приводящий к той же форме 400.
  • IntegrationError — в каждом adapters/out/<system>/errors.py. Наследники в своём пакете: payment-адаптер имеет PaymentGatewayError, etc.
  • TechnicalError — в core/errors.py, используется редко: 90% технических ошибок ловит catch-all в edge.

Корневой AppError

R-ERR-HIER-2: все четыре наследуют один корневой AppError, а не напрямую Exception.

# core/errors.py
class AppError(Exception):
    """Корень всех прикладных ошибок сервиса."""


class DomainError(AppError):
    """Нарушено бизнес-правило → 409/422, no-retry."""


class InputValidationError(AppError):
    """Невалидный вход → 400, no-retry. Не путать с pydantic.ValidationError."""


class IntegrationError(AppError):
    """Внешняя система ответила неожиданно → 502/503/504, retry-safe."""


class TechnicalError(AppError):
    """Внутренняя проблема → 500, retry-возможно."""

Зачем отдельный AppError:

  • Edge-handler матчит по типу, а не по тексту: isinstance(exc, DomainError) — явный контракт.
  • Catch-all Exception в register_error_handlers ловит то, что вышло за иерархию — это сигнал бага, не нормальная ветка.
  • Свой корень позволяет в будущем добавить общий контекст (correlation_id, service_name) без изменения всех наследников.

В Python нет checked exceptions, поэтому «не потерять тип» выполняется тем, что edge-handler матчит конкретные классы (DomainError, IntegrationError), а не Exception на всё.

Имена — по бизнес-смыслу

R-ERR-HIER-3: имя исключения отвечает на вопрос «что нарушено», не «как упало».

# core/domain/order/errors.py

# ХОРОШО
class OrderAlreadyShippedError(DomainError): ...
class InsufficientFundsError(DomainError): ...
class CustomerNotEligibleForRefundError(DomainError): ...
class ProductOutOfStockError(DomainError): ...

# ПЛОХО
class BusinessError(DomainError): ...            # что именно?
class ValidationFailedError(DomainError): ...    # какое поле, почему?
class IllegalStateError(DomainError): ...        # техническое имя без бизнес-смысла

Что даёт правильное имя:

  • Traceback читается как история. OrderAlreadyShippedError — сразу понятно. С BusinessError пришлось бы открывать детали.
  • Edge-handler может уточнить ответ. Отдельный handler для InsufficientFundsError с полями customer_id, requested, available в extension-полях ProblemDetails.
  • Метрики разделимы. app_errors_total{exception="InsufficientFundsError"} — отдельная серия в Prometheus. Можно алёртить на рост.

Имя — это публичный контракт. Переименование — breaking change для потребителей метрик и алёртов.

Префикс системы для Integration

R-ERR-HIER-4: наследники IntegrationError несут префикс системы.

# adapters/out/payment/errors.py
class PaymentGatewayError(IntegrationError): ...
class PaymentGatewayUnavailableError(PaymentGatewayError): ...   # CB открыт

# adapters/out/catalog/errors.py
class CatalogPortError(IntegrationError): ...
class CatalogPortTimeoutError(CatalogPortError): ...

# adapters/out/sms/errors.py
class SmsProviderError(IntegrationError): ...

Что это даёт edge-handler'у:

# app/error_handlers.py

async def _handle_payment_unavailable(request: Request, exc: PaymentGatewayUnavailableError) -> JSONResponse:
    return problem(503, "Payment provider temporarily unavailable",
                   "circuit breaker open", trace_id=get_trace_id())

async def _handle_catalog_error(request: Request, exc: CatalogPortError) -> JSONResponse:
    return problem(502, "Product catalog temporarily unavailable",
                   "catalog error", trace_id=get_trace_id())

Сообщение клиенту разное — «платёжный сервис недоступен» vs «каталог недоступен». Оператор по логам и метрикам видит, что именно деградировало.

dataclass-исключения для контекста

R-ERR-HIER-5: конструктор фиксирует контекст обязательно. В Python для этого идиоматично использовать @dataclass.

from dataclasses import dataclass
from decimal import Decimal


@dataclass
class InsufficientFundsError(DomainError):
    customer_id: str
    requested: Decimal
    available: Decimal

    def __str__(self) -> str:
        return (
            f"Insufficient funds: customer={self.customer_id}, "
            f"requested={self.requested}, available={self.available}"
        )


@dataclass
class OrderAlreadyShippedError(DomainError):
    order_id: str

    def __str__(self) -> str:
        return f"Order {self.order_id} is already shipped, cannot cancel"


@dataclass
class ProductOutOfStockError(DomainError):
    product_id: str
    requested_qty: int
    available_qty: int

    def __str__(self) -> str:
        return (
            f"Product {self.product_id} out of stock: "
            f"requested={self.requested_qty}, available={self.available_qty}"
        )

Использование в доменном коде:

# core/domain/order/aggregate.py

class Order:
    def cancel(self) -> None:
        if self.status == OrderStatus.SHIPPED:
            raise OrderAlreadyShippedError(order_id=self.order_id)
        self._status = OrderStatus.CANCELLED

    def charge(self, amount: Decimal, customer: Customer) -> None:
        if customer.balance < amount:
            raise InsufficientFundsError(
                customer_id=customer.customer_id,
                requested=amount,
                available=customer.balance,
            )

Что даёт @dataclass-исключение:

  • Контекст в объекте. exc.customer_id, exc.requested, exc.available — edge-handler достаёт поля для ProblemDetails-ответа.
  • __str__ — читаемый лог. logger.warning("domain rule violated: %s", exc) — полный контекст без дополнительных аргументов.
  • Явная сигнатура. InsufficientFundsError(customer_id=..., requested=..., available=...) — нельзя создать без контекста.

Альтернатива без @dataclass допустима, но требует ручного __init__:

class SberInvalidRequestError(IntegrationError):
    def __init__(self, order_id: str, upstream_detail: str) -> None:
        super().__init__(f"Sber rejected order {order_id}: {upstream_detail}")
        self.order_id = order_id
        self.upstream_detail = upstream_detail

Что запрещено

АнтипаттернПравилоЧто взамен
raise Exception("what happened")R-ERR-HIER-X1Конкретный наследник DomainError / IntegrationError
raise RuntimeError(e)R-ERR-HIER-X1raise PaymentGatewayError("context") from e
raise AssertionError("не то состояние") в домене как бизнес-правилоR-ERR-HIER-X2DomainError-наследник с контекстом
assert condition, "msg" как проверка бизнес-инварианта в OrderR-ERR-HIER-X2raise OrderAlreadyShippedError(order_id=...)
class BusinessError(DomainError): ... без бизнес-смысла в имениR-ERR-HIER-3Конкретное имя по нарушенному правилу
raise ValueError("bad state") в доменном кодеR-ERR-HIER-X2Домен не бросает ValueError — это DomainError-наследник

R-ERR-HIER-X1 подробно: raise Exception("Order already shipped") в доменном методе — тип теряется. Edge-handler не отличит бизнес-правило от технической проблемы: оба попадут в catch-all Exception → 500. Метрики покажут «много Exception» — бесполезно.

R-ERR-HIER-X2 подробно: assert self.status != OrderStatus.SHIPPED, "cannot cancel" — это семантика Python «программа в невозможном состоянии». Если бизнес-сценарий допускает попытку отменить отгруженный заказ (пользователь нажал кнопку — это нормально), это бизнес-правило, а не assertion. AssertionError при python -O вообще не выбрасывается. Нужен OrderAlreadyShippedError.

Куда дальше

  • Где throw, где catch — что куда летит после raise.
  • Mapping в ProblemDetails — как доменное исключение становится HTTP-ответом.
  • Логирование исключений — почему DomainError → WARNING, не ERROR.
  • Retry-семантика — что retry-safe, что нет.
  • Result types vs exceptions — когда Result допустим.
  • Observability ошибок — метрики и алёрты.
  • Error Handling Style Guide → раздел 1 — нормативные формулировки.