Опирается на правила:
R-ERR-HIER-1…R-ERR-HIER-5иR-ERR-HIER-X1…R-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-X1 | raise PaymentGatewayError("context") from e |
raise AssertionError("не то состояние") в домене как бизнес-правило | R-ERR-HIER-X2 | DomainError-наследник с контекстом |
assert condition, "msg" как проверка бизнес-инварианта в Order | R-ERR-HIER-X2 | raise 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 — нормативные формулировки.