Опирается на правила:
R-ERR-WHERE-1…R-ERR-WHERE-3иR-ERR-WHERE-X1…R-ERR-WHERE-X3из Error Handling Style Guide → раздел 2. Где throw, где catch.
Важно знать
- Raise — везде где нужно. Domain бросает
DomainError, валидатор —InputValidationError, out-adapter —IntegrationError.- Except — ровно в трёх местах:
register_error_handlers(app)— REST edge. Превращает исключение в HTTP-ответ.- Out-adapter — integration boundary. Ловит
httpx.HTTPStatusError/TimeoutException, бросает port-specific.- Резильянс-обёртка —
tenacity @retry,aiobreaker. Формальный catch через декоратор.- В UseCase Handler / Domain Service / Aggregate — ноль try/except. Исключения проходят насквозь до edge.
except Exception as e: logger.error("failed", exc_info=e)безraise— главный антипаттерн всего гайда. Глушит ошибку, возвращает «успех» вызывающему.except Exception as e: raise RuntimeError(e)— тип теряется. Edge получаетRuntimeError→ 500 на всё.except Exception: return None/return []— то же, что силент-фейл, только без логирования.
Главный принцип: исключение — часть контракта, не неожиданность. Domain-метод бросает типизированный DomainError; вызывающий его UseCase Handler не пытается его поймать — это задача edge. Чем меньше try/except в коде, тем чётче поток управления и тем меньше мест, где ошибка может потеряться. Раскрытие правил R-ERR-WHERE-* ниже.
Raise — без церемоний
R-ERR-WHERE-1: бросаем исключение там, где обнаружили проблему.
# core/domain/order/aggregate.py — Domain бросает DomainError
class Order:
def cancel(self, reason: CancellationReason) -> None:
if self.status == OrderStatus.SHIPPED:
raise OrderAlreadyShippedError(order_id=self.order_id)
self._status = OrderStatus.CANCELLED
self._cancellation_reason = reason
self._events.append(OrderCancelledEvent(order_id=self.order_id, reason=reason))
# core/use_cases/cancel_order.py — UseCase Handler не ловит, не оборачивает
class CancelOrderHandler:
def __init__(self, orders: OrderRepository, notifications: NotificationPort) -> None:
self._orders = orders
self._notifications = notifications
async def handle(self, cmd: CancelOrderCommand) -> Order:
order = await self._orders.get_by_id(cmd.order_id)
if order is None:
raise OrderNotFoundError(order_id=cmd.order_id)
order.cancel(cmd.reason) # ← бросит OrderAlreadyShippedError если что
await self._orders.save(order)
await self._notifications.notify_cancelled(order) # ← бросит IntegrationError если что
return order
# adapters/out/payment/client.py — out-adapter бросает port-specific IntegrationError
class PaymentClientAdapter(PaymentPort):
async def charge(self, cmd: ChargeCommand) -> ChargeResult:
try:
resp = await self._client.post("/charge", json=_to_api(cmd))
resp.raise_for_status()
return _to_domain(resp.json())
except httpx.HTTPStatusError as e:
if e.response.status_code < 500:
raise InvalidPaymentRequestError(order_id=cmd.order_id) from e
raise PaymentGatewayError("payment 5xx on charge") from e
except (httpx.TimeoutException, httpx.TransportError) as e:
raise PaymentGatewayError("payment timeout") from e
Не пытаемся «изобретать» returns.Result везде (R-ERR-RESULT-X1 — см. Result types vs exceptions). Исключения и типизированная иерархия — достаточный контракт.
Точка 1 — register_error_handlers
R-ERR-WHERE-2-a: один модуль app/error_handlers.py на сервис, per-type обработчики.
# app/error_handlers.py
import structlog
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from core.errors import DomainError, InputValidationError, IntegrationError, TechnicalError
from app.problem import problem
from app.tracing import get_trace_id
from app.metrics import app_errors_total
logger = structlog.get_logger()
def register_error_handlers(app: FastAPI) -> None:
app.add_exception_handler(DomainError, _handle_domain)
app.add_exception_handler(InputValidationError, _handle_validation)
app.add_exception_handler(IntegrationError, _handle_integration)
app.add_exception_handler(TechnicalError, _handle_technical)
app.add_exception_handler(RequestValidationError, _handle_pydantic)
app.add_exception_handler(Exception, _handle_unexpected)
async def _handle_domain(request: Request, exc: DomainError) -> JSONResponse:
logger.warning("domain rule violated", error=str(exc), exc_type=type(exc).__name__)
app_errors_total.labels(type="domain", exception=type(exc).__name__).inc()
return problem(422, "Operation cannot be completed", str(exc),
type_="https://api.example.com/errors/domain-rule",
trace_id=get_trace_id())
async def _handle_validation(request: Request, exc: InputValidationError) -> JSONResponse:
app_errors_total.labels(type="validation", exception=type(exc).__name__).inc()
return problem(400, "Validation failed", str(exc), trace_id=get_trace_id())
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",
trace_id=get_trace_id(), errors=errors)
async def _handle_integration(request: Request, exc: IntegrationError) -> JSONResponse:
logger.warning("integration error", error=str(exc), exc_type=type(exc).__name__)
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())
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())
async def _handle_unexpected(request: Request, exc: Exception) -> JSONResponse:
logger.error("unexpected error", exc_info=exc)
app_errors_total.labels(type="unexpected", exception=type(exc).__name__).inc()
return problem(500, "Internal Server Error", "internal error", trace_id=get_trace_id())
Что важно:
- Catch-all
Exception— не глушит, не возвращает 200. Логирует ERROR + stacktrace, отдаёт 500. - Per-type handler — разные HTTP-статусы, разные log-level, разные ProblemDetails.
RequestValidationError(Pydantic) — отдельный handler, приводящий к нашей форме 400 сerrors-массивом.
Обобщённый _handle_domain выше возвращает 422. Для конфликтов состояния (OrderAlreadyShippedError, EmailAlreadyTakenError) нужен 409 — зарегистрировать отдельный handler. Подробнее: Mapping в ProblemDetails.
Вызов в точке старта приложения:
# app/main.py
from fastapi import FastAPI
from app.error_handlers import register_error_handlers
app = FastAPI()
register_error_handlers(app)
Точка 2 — out-adapter (integration boundary)
R-ERR-WHERE-2-b: httpx-адаптер ловит низкоуровневые ошибки клиента, бросает port-specific.
# adapters/out/sber/client.py
import httpx
from adapters.out.sber.errors import (
SberGatewayError,
SberGatewayUnavailableError,
InvalidSberRequestError,
)
from core.ports.payment_port import PaymentPort
class SberClientAdapter(PaymentPort):
def __init__(self, client: httpx.AsyncClient) -> None:
self._client = client
async def register(self, cmd: RegisterCommand) -> RegisterResult:
try:
resp = await self._client.post("/register", json=_to_api(cmd))
resp.raise_for_status()
return _to_domain(resp.json())
except httpx.HTTPStatusError as e:
if e.response.status_code < 500: # 4xx → domain, R-ERR-RETRY-2
raise InvalidSberRequestError(order_id=cmd.order_id) from e
raise SberGatewayError("sber 5xx on register") from e
except httpx.TimeoutException as e:
raise SberGatewayError("sber timeout on register") from e
except httpx.TransportError as e:
raise SberGatewayError("sber transport error on register") from e
Что важно:
- Out-adapter — единственное место, где знают про
httpx.HTTPStatusError,httpx.TimeoutException. Эти типы — детали инфраструктуры, не должны утекать вcore/. - Mapping в port-specific даёт
coreвозможность работать с типизированными ошибками:SberGatewayError,InvalidSberRequestError. - 4xx vs 5xx — разная семантика. 4xx →
DomainError-наследник (InvalidSberRequestError) — retry не поможет; 5xx →IntegrationError-наследник (SberGatewayError) — retry-safe при идемпотентности. from e— обязательно. Сохраняет исходный traceback (__cause__).
Точка 3 — резильянс-обёртки
R-ERR-WHERE-2-c: tenacity @retry и aiobreaker — формальный catch через декоратор.
# adapters/out/sber/client.py
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
from adapters.out.sber.errors import SberGatewayError
class SberClientAdapter(PaymentPort):
@retry(
retry=retry_if_exception_type(SberGatewayError),
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=0.2, max=2),
reraise=True,
)
async def register(self, cmd: RegisterCommand) -> RegisterResult:
...
retry_if_exception_type(SberGatewayError) означает: retry только на SberGatewayError (5xx/timeout). InvalidSberRequestError (4xx) не совпадает — retry не произойдёт. Это и есть R-ERR-RETRY-2 в action.
При использовании aiobreaker — когда цепь разомкнута, он бросает CircuitBreakerError. Caller маппит его в port-specific SberGatewayUnavailableError:
# adapters/out/sber/client.py
from aiobreaker import CircuitBreaker, CircuitBreakerError
from adapters.out.sber.errors import SberGatewayError, SberGatewayUnavailableError
breaker = CircuitBreaker(fail_max=5, timeout_duration=30)
class SberClientAdapter(PaymentPort):
async def register(self, cmd: RegisterCommand) -> RegisterResult:
try:
return await breaker.call_async(self._do_register, cmd)
except CircuitBreakerError as e:
raise SberGatewayUnavailableError(order_id=cmd.order_id) from e
async def _do_register(self, cmd: RegisterCommand) -> RegisterResult:
resp = await self._client.post("/register", json=_to_api(cmd))
resp.raise_for_status()
return _to_domain(resp.json())
aiobreaker не имеет механизма регистрации fallback-методов — разомкнутая цепь выбрасывает CircuitBreakerError, который вызывающий код явно превращает в доменный тип.
Внутри резильянс-обёртки:
- Не пишем свой try/except на тот же тип —
tenacityуже его поймал. reraise=True— после исчерпания попыток исключение пробрасывается до edge.- Детали — Retry-семантика.
Нигде больше — ноль try/except
R-ERR-WHERE-3: в UseCase Handler / Domain Service / Aggregate — ноль try/except.
# core/use_cases/create_order.py
class CreateOrderHandler:
def __init__(
self,
products: ProductRepository,
orders: OrderRepository,
payment: PaymentPort,
) -> None:
self._products = products
self._orders = orders
self._payment = payment
async def handle(self, cmd: CreateOrderCommand) -> Order:
product = await self._products.get_by_id(cmd.product_id)
if product is None:
raise ProductNotFoundError(product_id=cmd.product_id)
order = Order.create(cmd, product) # ← бросит DomainError если правило нарушено
await self._orders.save(order)
await self._payment.register( # ← бросит IntegrationError если что
RegisterCommand(order_id=order.order_id, amount=order.total)
)
return order
Никаких try: order = Order.create(...) except DomainError as e: .... Если правило нарушено, edge превратит в 422. Никаких try: await payment.register(...) except IntegrationError. Если payment сломан, edge превратит в 502. Handler тонкий — он только оркеструет; логика — в Order.create, обработка ошибок — в error_handlers.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
except Exception as e: logger.error(...); return None | R-ERR-WHERE-X1 | Не ловить в handler/service совсем |
except Exception as e: raise RuntimeError(e) | R-ERR-WHERE-X2 | raise PaymentGatewayError(...) from e |
except Exception: return [] / return {} | R-ERR-WHERE-X3 | Не ловить — пусть летит до edge |
try/except в UseCase Handler | R-ERR-WHERE-3 | Ноль try/except вне трёх точек |
try/except в Domain Service | R-ERR-WHERE-3 | Ноль try/except вне трёх точек |
R-ERR-WHERE-X1 подробно — почему это катастрофа:
# ПЛОХО — силент-фейл в handler
async def handle(self, cmd: CreateOrderCommand) -> Order | None:
try:
product = await self._products.get_by_id(cmd.product_id)
order = Order.create(cmd, product)
await self._orders.save(order)
return order
except Exception as e:
logger.error("failed to create order", exc_info=e)
return None # ← возвращает «успех»
Что катастрофически не так:
- Возвращает успех вызывающему. Контроллер получает
None, продолжает работу, упадёт в другом месте — NullPointerError там, где ожидали объект. - Метрика не инкрементируется.
app_errors_totalне увидит ошибку — её «не было». - Span не помечается ERROR. Trace в Jaeger покажет зелёный span, хотя всё упало.
R-ERR-WHERE-X2: raise RuntimeError(e) — тип теряется. Edge получает RuntimeError → catch-all → 500 на всё. PaymentGatewayError → 502 и InsufficientFundsError → 422 становятся неразличимы. Правильно: raise PaymentGatewayError("context") from e.
Куда дальше
- Иерархия исключений — какие типы бросать.
- Mapping в ProblemDetails — что делает
register_error_handlersс пойманными. - Retry-семантика — какие исключения retry-safe.
- Логирование исключений — почему логируем один раз на edge.
- Result types vs exceptions — когда
Resultдопустим. - Observability ошибок — метрики на ошибки.
- Error Handling Style Guide → раздел 2 — нормативные формулировки.