Опирается на правила: R-ERR-WHERE-1R-ERR-WHERE-3 и R-ERR-WHERE-X1R-ERR-WHERE-X3 из Error Handling Style Guide → раздел 2. Где throw, где catch.

Важно знать

  • Raise — везде где нужно. Domain бросает DomainError, валидатор — InputValidationError, out-adapter — IntegrationError.
  • Except — ровно в трёх местах:
    1. register_error_handlers(app) — REST edge. Превращает исключение в HTTP-ответ.
    2. Out-adapter — integration boundary. Ловит httpx.HTTPStatusError/TimeoutException, бросает port-specific.
    3. Резильянс-обёртка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 NoneR-ERR-WHERE-X1Не ловить в handler/service совсем
except Exception as e: raise RuntimeError(e)R-ERR-WHERE-X2raise PaymentGatewayError(...) from e
except Exception: return [] / return {}R-ERR-WHERE-X3Не ловить — пусть летит до edge
try/except в UseCase HandlerR-ERR-WHERE-3Ноль try/except вне трёх точек
try/except в Domain ServiceR-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 — нормативные формулировки.