Опирается на правила: R-ERR-RETRY-1R-ERR-RETRY-3 и R-ERR-RETRY-X1 из Error Handling Style Guide → раздел 5. Retry / no-retry семантика.

Важно знать

  • DomainErrorникогда не retry. Бизнес-правило детерминированно: те же данные → тот же fail.
  • InputValidationErrorникогда не retry. Тот же вход → тот же fail.
  • IntegrationErrorretry-safe при идемпотентности. Write без Idempotency-Key — retry запрещён.
  • TechnicalError — обычно retry после latency.
  • HTTP 4xx от внешней системы — не retry. «Послали некорректное» — повтор не поможет.
  • HTTP 5xx и timeout — retry-safe только при идемпотентности. Без Idempotency-Key на write — деньги могут списаться дважды.
  • tenacity @retry — на методах out-adapter, не на edge-handler. На edge retry бессмысленен.

Retry — дешёвый способ пережить транзиентный сбой внешней системы. Но та же механика может стать оружием против целостности: повторно списать деньги, отправить SMS, создать платёж. Правило в одну фразу: retry безопасен только когда операция идемпотентна. И тип исключения — первый признак, retry'ить ли вообще. Раскрытие правил R-ERR-RETRY-* ниже.

Таблица по типам

R-ERR-RETRY-1: однозначный ответ на «retry или нет» по типу.

ТипRetryПричина
DomainError❌ НикогдаБизнес-правило детерминированно. Тот же state + тот же запрос → тот же fail.
InputValidationError❌ НикогдаНевалидный вход. Те же данные → тот же fail.
IntegrationError✅ При идемпотентностиСетевой сбой / 5xx — обычно транзиентно. Но только если операция идемпотентна.
TechnicalError✅ После latencyВнутренняя проблема (БД-таймаут). Часто проходит сама.

«Retry» здесь — автоматический retry внутри сервиса через tenacity. Клиентский retry (пользователь нажимает кнопку ещё раз) — отдельная история, его контролирует клиент.

DomainError и InputValidationError — никогда

# ПЛОХО — retry на handler, который может бросить DomainError
from tenacity import retry, stop_after_attempt, wait_fixed

@retry(stop=stop_after_attempt(3), wait=wait_fixed(1))
async def handle(self, cmd: CreateOrderCommand) -> Order:
    product = await self._products.get_by_id(cmd.product_id)
    order = Order.create(cmd, product)       # ← может бросить OrderTooLargeError
    ...

Если Order.create(...) бросает OrderTooLargeError («заказ превышает лимит 100 000 ₽»), retry не поможет:

  • Состояние агрегата не изменится между попытками — заказ всё ещё слишком большой.
  • Правило детерминированно — сработает на каждой попытке одинаково.
  • Получим 3 одинаковых WARNING-лога подряд и спустя 3 секунды ту же 422-ошибку.

Tenacity при retry на исключение типа DomainError нужно явно исключить:

from tenacity import (
    retry,
    retry_if_not_exception_type,
    stop_after_attempt,
    wait_exponential,
)
from core.errors import DomainError, InputValidationError

@retry(
    retry=retry_if_not_exception_type((DomainError, InputValidationError)),
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=0.2, max=2),
    reraise=True,
)
async def some_method_with_integration(self, cmd: ...) -> ...:
    ...

Лучше — retry_if_exception_type на конкретный Integration-тип, не инверсия на всё:

@retry(
    retry=retry_if_exception_type(PaymentGatewayError),   # только 5xx/timeout
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=0.2, max=2),
    reraise=True,
)
async def register_payment(self, cmd: RegisterCommand) -> RegisterResult:
    return await self._adapter.register(cmd)

IntegrationError — retry-safe при идемпотентности

R-ERR-RETRY-3: HTTP 5xx и timeout — транзиентные сбои, обычно проходят. Но повторять можно только если операция идемпотентна.

Идемпотентность — «повторный вызов с теми же параметрами даёт тот же результат, что и одиночный».

Read-операции (get_order, list_products) — естественно идемпотентны. Retry safe:

@retry(
    retry=retry_if_exception_type(CatalogPortError),
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=0.1, max=1),
    reraise=True,
)
async def get_product(self, product_id: str) -> Product:
    resp = await self._client.get(f"/products/{product_id}")
    resp.raise_for_status()
    return _to_domain(resp.json())

Write-операции (charge_payment, create_order) — идемпотентны только при наличии Idempotency-Key:

# ПЛОХО — retry write без идемпотентности
@retry(
    retry=retry_if_exception_type(PaymentGatewayError),
    stop=stop_after_attempt(3),
    reraise=True,
)
async def charge(self, cmd: ChargeCommand) -> ChargeResult:
    resp = await self._client.post("/charge", json=_to_api(cmd))
    resp.raise_for_status()
    return _to_domain(resp.json())

Если первая попытка успешно списала 5 000 ₽, но timeout — ответ потерян в сети. Tenacity повторит. Платёжная система снова спишет 5 000 ₽. Пользователю снимут 10 000 ₽.

# ХОРОШО — Idempotency-Key передаётся, retry безопасен
@retry(
    retry=retry_if_exception_type(PaymentGatewayError),
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=0.5, max=4),
    reraise=True,
)
async def charge(self, cmd: ChargeCommand) -> ChargeResult:
    resp = await self._client.post(
        "/charge",
        json=_to_api(cmd),
        headers={"Idempotency-Key": cmd.idempotency_key},
    )
    resp.raise_for_status()
    return _to_domain(resp.json())

Платёжная система по Idempotency-Key при повторе вернёт результат первой попытки, не списав повторно.

Откуда берётся idempotency_key:

# core/use_cases/create_order.py
@dataclass
class CreateOrderCommand:
    product_id: str
    customer_id: str
    amount: Decimal
    idempotency_key: str = field(default_factory=lambda: str(uuid.uuid4()))

Генерируется клиентом (или UseCase создаёт при первом вызове) и хранится — при ретрае клиент отправляет тот же ключ.

HTTP 4xx — не retry

R-ERR-RETRY-2: 4xx означает «мы послали что-то некорректное». Повтор с теми же данными даст тот же 4xx.

# adapters/out/sber/client.py
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
            raise InvalidSberRequestError(              # DomainError-наследник
                order_id=cmd.order_id
            ) from e
        raise SberGatewayError("sber 5xx on register") from e   # IntegrationError
    except httpx.TimeoutException as e:
        raise SberGatewayError("sber timeout") from e           # IntegrationError

InvalidSberRequestErrorDomainError-наследник. Tenacity с retry_if_exception_type(SberGatewayError) его не затронет — он другого типа. Edge превратит в 422 для нашего клиента.

Бизнес-смысл: 4xx от внешки = «наш запрос некорректен с её точки зрения». Это нужно разобрать и исправить на уровне бизнес-логики — retry здесь бессмысленен.

tenacity — конфигурация

Стандартная конфигурация для out-adapter с retry только на транзиентные ошибки:

import logging

from tenacity import (
    retry,
    retry_if_exception_type,
    stop_after_attempt,
    wait_exponential,
    before_sleep_log,
)
import structlog

logger = structlog.get_logger()


def sber_retry():
    return retry(
        retry=retry_if_exception_type(SberGatewayError),
        stop=stop_after_attempt(3),
        wait=wait_exponential(multiplier=0.2, max=2),
        before_sleep=before_sleep_log(logger, logging.WARNING),
        reraise=True,
    )


class SberClientAdapter(PaymentPort):
    @sber_retry()
    async def register(self, cmd: RegisterCommand) -> RegisterResult:
        ...

    @sber_retry()
    async def get_status(self, order_id: str) -> PaymentStatus:
        ...

before_sleep_log — будет логировать каждую retry-попытку на logging.WARNING. При 3 неудачных попытках в логах будут 3 WARNING + финальный ERROR из edge-handler (один раз). Это нормально и ожидаемо.

reraise=True — после исчерпания попыток исключение пробрасывается как есть до edge. Без этого tenacity поднимает tenacity.RetryError — тип нашей иерархии теряется.

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

АнтипаттернПравилоЧто взамен
@retry на edge-handler функцииR-ERR-RETRY-X1@retry только на методах out-adapter
Retry write без Idempotency-KeyR-ERR-RETRY-3Передавать Idempotency-Key в заголовке
retry_if_not_exception_type(Exception) — retry всегоretry_if_exception_type(ConcreteIntegrationError)
Retry при HTTP 4xx от внешкиR-ERR-RETRY-2Маппить в DomainError-наследник, не ретраить
reraise=False (по умолчанию) с tenacity — глотает ошибкуreraise=True всегда

R-ERR-RETRY-X1 подробно — retry на edge-handler бессмысленен:

# ПЛОХО
async def _handle_integration(request: Request, exc: IntegrationError) -> JSONResponse:
    # tenacity здесь ничего не сделает: исключение уже поймано,
    # мы формируем HTTP-ответ — «повторить» некуда
    ...

Edge-handler уже вне retry-цикла. Исключение долетело сюда — значит, все @retry в out-adapter и резильянс-обёртках уже отработали (или не были настроены). Retry на «как мы отвечаем клиенту HTTP-ошибкой» — бессмыслица.

Retry — в out-adapter, на методах port-вызовов. Не в edge, не в UseCase Handler.

Куда дальше

  • Иерархия исключений — как 4xx маппится в DomainError, 5xx — в IntegrationError.
  • Где raise, где catch — три точки catch; out-adapter как вторая.
  • Mapping в ProblemDetails — что edge отдаёт клиенту после исчерпания retry.
  • Логирование исключений — WARNING vs ERROR при retry.
  • Result types vs exceptions — почему не Result вместо retry.
  • Observability ошибок — метрики circuit breaker и интеграций.
  • Error Handling Style Guide → раздел 5 — нормативные формулировки.