Опирается на правила: R-RES-RE-1R-RES-RE-5 и R-RES-RE-X1R-RES-RE-X4 из Resilience Style Guide → раздел 5. Retry.

Важно знать

  • @retry допустим только при идемпотентности: либо метод — read (GET-эквивалент), либо команда с Idempotency-Key (внешняя система дедуплицирует).
  • stop_after_attempt(3) — типовое (включая первую попытку). 5 — верхний предел. Больше — task-queue.
  • wait_exponential обязателен. Линейный retry (wait_fixed) бьёт пачкой по и без того лежачей системе.
  • retry_if_exception_typehttpx.TimeoutException, httpx.ConnectError, и 5xx через кастомный предикат. 4xx — контрактная ошибка клиента, повтор не поможет.
  • In-memory retry для транзиентов <5s. Task-queue для отказов >30s.
  • @retry на write без Idempotency-Key — главный источник двойных платежей. На 5xx ответ может быть «не дошло» или «дошло, но ответ потерян».
  • Декоратор на public-методе out-adapter, не на httpx-вызове внутри, не на handler, не на репозитории.

Retry — самый опасный из resilience-инструментов. Большинство retry-инцидентов в проде — «дважды списали деньги», «отправили SMS трижды», «создали два заказа». Правило простое: retry только тогда, когда операция идемпотентна по дизайну. Любое сомнение — нет retry. Раскрытие раздела 5 гайда.

Когда retry допустим

R-RES-RE-1: ровно два случая.

Случай 1: read-операция (GET-эквивалент)

Чтение по определению идемпотентно: 1 запрос или 100 запросов — результат тот же, побочных эффектов нет.

# adapters/out/sber/sber_adapter.py
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception

def _is_transient(exc: BaseException) -> bool:
    import httpx
    if isinstance(exc, (httpx.TimeoutException, httpx.ConnectError)):
        return True
    if isinstance(exc, httpx.HTTPStatusError):
        return exc.response.status_code >= 500
    return False

class SberAdapter:
    @retry(
        stop=stop_after_attempt(3),
        wait=wait_exponential(multiplier=1, min=0.5, max=4),
        retry=retry_if_exception(_is_transient),
        reraise=True,
    )
    async def get_order_status(self, order_id: str) -> OrderStatus:
        async with asyncio.timeout(self._settings.total_timeout):
            resp = await self._client.get(f"/orders/{order_id}/status")
            resp.raise_for_status()
            return _to_order_status(resp.json())

Здесь retry безопасен. Если первый вызов вернул transient 503, второй попробует снова — максимум потратит несколько сотен миллисекунд дополнительно.

Случай 2: write с Idempotency-Key

Если внешняя система обязалась дедуплицировать по ключу, retry безопасен.

# adapters/out/sber/sber_adapter.py
class SberAdapter:
    @retry(
        stop=stop_after_attempt(3),
        wait=wait_exponential(multiplier=1, min=0.5, max=4),
        retry=retry_if_exception(_is_transient),
        reraise=True,
    )
    async def register(self, cmd: RegisterPaymentCommand) -> PaymentRef:
        payload = _to_sber_register(cmd)
        headers = {"Idempotency-Key": str(cmd.idempotency_key)}  # ← обязательно
        async with asyncio.timeout(self._settings.total_timeout):
            resp = await self._client.post("/register", json=payload, headers=headers)
            resp.raise_for_status()
            return _to_payment_ref(resp.json())

Что критично:

  • idempotency_keyдетерминированный (UUID v5 из order_id + operation или client-generated UUID v7 один раз на user-action). См. AUTH-19.
  • Внешняя система обязана гарантировать дедупликацию. Если гарантия не прописана в контракте — retry на write запрещён даже с ключом.
  • TTL ключа на стороне внешней системы должен быть больше нашего максимального retry-окна.

Конфиг retry через pydantic-settings

R-RES-RE-2 / R-RES-CFG-1: параметры — через pydantic-settings, не хардкодом в декораторе.

# adapters/out/sber/settings.py
from pydantic_settings import BaseSettings

class SberClientSettings(BaseSettings):
    retry_attempts: int = 3
    retry_wait_min: float = 0.5      # секунды
    retry_wait_max: float = 4.0
    retry_multiplier: float = 1.0
    connect_timeout: float = 2.0
    read_timeout: float = 10.0
    total_timeout: float = 15.0

    model_config = {"env_prefix": "CLIENT_SBER__"}
# adapters/out/sber/sber_adapter.py
def _make_retry(s: SberClientSettings):
    return retry(
        stop=stop_after_attempt(s.retry_attempts),
        wait=wait_exponential(
            multiplier=s.retry_multiplier,
            min=s.retry_wait_min,
            max=s.retry_wait_max,
        ),
        retry=retry_if_exception(_is_transient),
        reraise=True,
    )

class SberAdapter:
    def __init__(self, client: httpx.AsyncClient, breaker, sem: asyncio.Semaphore, settings: SberClientSettings) -> None:
        self._client = client
        self._breaker = breaker
        self._sem = sem
        self._settings = settings
        self._retry = _make_retry(settings)

    async def get_order_status(self, order_id: str) -> OrderStatus:
        return await self._retry(self._get_order_status_inner)(order_id)

    async def _get_order_status_inner(self, order_id: str) -> OrderStatus:
        async with self._sem:
            async with self._breaker:
                async with asyncio.timeout(self._settings.total_timeout):
                    resp = await self._client.get(f"/orders/{order_id}/status")
                    resp.raise_for_status()
                    return _to_order_status(resp.json())

В переменных окружения: CLIENT_SBER__RETRY_ATTEMPTS=3, CLIENT_SBER__RETRY_WAIT_MIN=0.5 — параметры меняются без перевыкладки кода.

stop_after_attempt — 3 типовое, 5 предел

R-RES-RE-3: больше 5 попыток — это уже не in-memory retry, это task-queue.

  • 3 попытки покрывают транзиентные сбои: connection reset, перезапуск пода внешней системы (k8s rolling update — 1–2 секунды).
  • 5 попыток — для нестабильных систем с высокой базовой ошибкой.
  • 10+ попыток — нет. Такая картина говорит о реальной деградации; нужно открыть CB и идти в fallback или ставить задачу в task-queue.

Время ожидания между попытками при wait_exponential(min=0.5, max=4, multiplier=1):

ПопыткаПауза до неё
2-я0.5s
3-я1.0s
4-я2.0s
5-я4.0s

Total in-memory window при 3 попытках ≈ 1.5s + 3 × total_timeout. Это вписывается в SLA upstream-вызова (<5s).

Граница in-memory retry vs task-queue

R-RES-RE-4: критический порог — около 5 секунд суммарного in-memory времени.

Длительность отказаИнструментПочему
<5s (транзиент)In-memory @retryБыстро, бесплатно, не нагружает БД
5–30s (обсуждать)Обычно task-queueSync-block handler на 30s = timeout upstream
>30s (длительный отказ)Task-queue с DB-driven schedulerПереживает рестарт сервиса, не блокирует event-loop

In-memory retry на 30s = event-loop в зомби: 200 тасок сидят в tenacity-retry блоке по 30s. Новые запросы получают SemaphoreError или отказ bulkhead. Сервис деградирует из-за retry, а не из-за внешней системы.

Task-queue retry

R-RES-RE-5: durable retry — через таблицу БД с polling-scheduler.

CREATE TABLE order_confirmation_task (
    task_id         BIGSERIAL PRIMARY KEY,
    order_id        BIGINT NOT NULL,
    status          TEXT NOT NULL,
    retry_count     INTEGER NOT NULL DEFAULT 0,
    next_attempt_at TIMESTAMPTZ NOT NULL,
    last_error      TEXT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
# adapters/out/sber/confirmation_scheduler.py
import asyncio
from datetime import datetime, timedelta, timezone

async def run_due_confirmations(repo: ConfirmationTaskRepo, adapter: SberAdapter) -> None:
    tasks = await repo.find_due(limit=50)  # SELECT ... FOR UPDATE SKIP LOCKED
    for task in tasks:
        try:
            await adapter.confirm_order(task.order_id)
            await repo.mark_completed(task.task_id)
        except Exception as exc:
            next_count = task.retry_count + 1
            if next_count >= 10:
                await repo.mark_failed(task.task_id, str(exc))
                await alert_ops(task)      # ← человек должен увидеть
            else:
                backoff = timedelta(seconds=2 ** next_count)
                next_at = datetime.now(tz=timezone.utc) + backoff
                await repo.schedule_retry(task.task_id, next_count, next_at, str(exc))

# FastAPI lifespan или APScheduler
async def scheduler_loop() -> None:
    while True:
        await run_due_confirmations(...)
        await asyncio.sleep(5)

Подробно — в Async и polling.

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

АнтипаттернПравилоЧто взамен
@retry на write без Idempotency-KeyR-RES-RE-X1Либо ключ, либо без retry
@retry на 4xx (httpx.HTTPStatusError с 4xx)R-RES-RE-X2retry_if_exception(_is_transient) — 4xx не транзиент
wait_fixed вместо wait_exponentialR-RES-RE-X3wait_exponential(multiplier=1, min=0.5, max=4)
stop_after_attempt > 5 для in-memory retryR-RES-RE-3Task-queue с DB
In-memory retry для отказов >30sR-RES-RE-4Task-queue (durable, переживает рестарт)
Retry без интеграции с CB (retry считается CB как failure)R-RES-CB-1CB + retry на одном методе; retry внутри — CB снаружи
@retry на репозитории / in-memory операцииR-RES-WHERE-X1Только на outbound HTTP-вызовах

Куда дальше

  • Async и polling — task-queue для долгих отказов.
  • Circuit Breaker — fast-fail когда retry не помогает.
  • Bulkhead — asyncio.Semaphore рядом с retry на том же методе.
  • Fallback — что делать когда retry исчерпан.
  • Конфигурация — pydantic-settings, дефолты и per-system override.
  • Health checks — как CB и retry влияют на readiness.
  • Observability — метрики tenacity через prometheus-client.
  • Timeouts — иерархия connect < read < total вокруг retry.
  • Где какая защита — retry только для outbound, не для SQL.
  • Per-system isolation — отдельный retry-конфиг на каждую систему.
  • OpenAPI generator binding — обёртки на адаптере, не на сгенерированном клиенте.