Опирается на правила:
R-RES-RE-1…R-RES-RE-5иR-RES-RE-X1…R-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_type—httpx.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-queue | Sync-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-Key | R-RES-RE-X1 | Либо ключ, либо без retry |
@retry на 4xx (httpx.HTTPStatusError с 4xx) | R-RES-RE-X2 | retry_if_exception(_is_transient) — 4xx не транзиент |
wait_fixed вместо wait_exponential | R-RES-RE-X3 | wait_exponential(multiplier=1, min=0.5, max=4) |
stop_after_attempt > 5 для in-memory retry | R-RES-RE-3 | Task-queue с DB |
| In-memory retry для отказов >30s | R-RES-RE-4 | Task-queue (durable, переживает рестарт) |
| Retry без интеграции с CB (retry считается CB как failure) | R-RES-CB-1 | CB + 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 — обёртки на адаптере, не на сгенерированном клиенте.