Опирается на правила:
R-ERR-RETRY-1…R-ERR-RETRY-3иR-ERR-RETRY-X1из Error Handling Style Guide → раздел 5. Retry / no-retry семантика.
Важно знать
DomainError— никогда не retry. Бизнес-правило детерминированно: те же данные → тот же fail.InputValidationError— никогда не retry. Тот же вход → тот же fail.IntegrationError— retry-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
InvalidSberRequestError — DomainError-наследник. 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-Key | R-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 — нормативные формулировки.