Прямой ответ
Любой вызов через сеть когда-нибудь упадёт, и наивный retry делает хуже: мгновенные повторы на лежащий сервис превращают локальный сбой в лавину (клиенты умножают нагрузку ×N ровно тогда, когда сервису тяжелее всего — retry storm). Рабочий набор из трёх слоёв. Retry с экспоненциальным backoff и джиттером: задержка растёт (0.5 с → 1 → 2 → 4, cap 30–60 с) и обязательно случайна (full jitter: sleep(random.uniform(0, base)) — одинаковые задержки у тысяч клиентов синхронизируют волну повторов — джиттер размывает; эталонный разбор — AWS Builders Library в шапке); ретраить только переходящие ошибки (таймауты, connect error, 429/502/503 — не 400/404/422: повтор на «неверный запрос» бессмыслен) и только идемпотентные операции (GET безопасно; POST — при поддержке идемпотентности — ключи идемпотентности); бюджет повторов конечен (3–5 попыток, суммарный таймаут операции) — бесконечный retry запрещён. Circuit breaker поверх: счётчик последовательных/долевых отказов → «открыт» (запросы не уходят вовсе, сразу ошибка — сервис отдыхает, ваш пул соединений не висит в таймаутах) → через окно «полуоткрыт» (пробный запрос — успех закрывает цепь) — готовое: pybreaker/purgatory или декоратор на tenacity+счётчик; fallback на открытом breaker'е — деградация по продукту (кеш, очередь, честная ошибка — не «вечный спиннер»). Bulkhead/таймауты фундаментом: отдельные лимиты на каждый внешний сервис (таймаут connect 2–5 с, read — по SLA сервиса), ограниченные пулы (httpx-лимиты) — «один медленный партнёр не должен съедать все воркеры» (Celery-изоляция — та же логика в очередях). И наблюдаемость: метрики попыток/отказов/состояния breaker'а — retry-политика без метрик невидима и потому опасна.
Retry: что, когда и сколько повторять
Классификация ошибок до retry (половина правильности): переходящие — сетевые (ConnectionError, таймауты), 429 (с Retry-After — уважать заголовок: сервер сказал, когда можно — backoff не короче), 502/503/504 (прокси/перегрузка) — ретраить; постоянные — 400/401/403/404/409/422 (запрос неверен/неавторизован/конфликт) — не ретраить (исправить запрос — не повторять); неопределённые — таймаут чтения на POST (запрос мог исполниться!) — ретраить только идемпотентно (ключ идемпотентности — сервер дедуплицирует — контракт с API); 500 — обычно переходящая (баг сервиса может и не пройти — ограниченное число попыток). Реализация в Python — tenacity: @retry(stop=stop_after_attempt(4), wait=wait_random_exponential(multiplier=0.5, max=30), retry=retry_if_exception_type((httpx.TransportError,)), before_sleep=лог) — экспоненциально+джиттер+фильтр исключений+лог повторов (молчаливые ретраи — «интерфейс тормозит» без следов в логах — структура логов); httpx — встроенные transport retries только для connect-уровня (mounts/HTTPTransport(retries=2)) — прикладные ретраи — tenacity/вручную. Бюджет: суммарное время операции с ретраями ≤ таймаута пользователя/воркера (4 попытки × (запрос + backoff) — считать; «ретраим 10 раз по 30 с» внутри 30-секундного HTTP-запроса от клиента — клиент давно отвалился, работа впустую — и воркер занят); для фоновых задач — щедрее (минуты, Celery-ретраи с retry_backoff — та же математика в очереди); hedged requests (продвинутый уровень — второй запрос параллельно при затянувшемся первом — для идемпотентных чтений на хвостовую латентность — осторожно с нагрузкой ×2 в хвосте).
Circuit breaker и деградация
Механика состояний: closed (запросы идут, счётчик отказов в скользящем окне) → порог (доля отказов > 50% на 20+ запросах или N последовательных) → open (fast-fail без сети — клиент получает ошибку/фолбэк мгновенно — сервис не добивается, ваши соединения не копятся) → recovery_timeout (15–60 с) → half-open (пробные запросы — успех → closed, провал → open снова); параметры тюнить под сервис (быстрый flapping — порог выше/окно длиннее; критичный партнёр — recovery короче). Готовое: pybreaker (синхронный/асинк, состояния, слушатели событий — метрики на переключения), purgatory (asyncio-first); свой на декораторе — ~50 строк (счётчик+состояние+время — при желании полного контроля — декораторы). Fallback — обязательная часть паттерна (breaker без фолбэка — просто «быстрая ошибка»): кеш последних значений (устаревший курс валют лучше отсутствия — кеширование), очередь на повтор (операция отложена — пользователь предупреждён — очереди), урезанная функциональность (страница без блока рекомендаций — остальное работает — feature-деградация по флагу), честная ошибка с retry-подсказкой; анти-фолбэк — блокирующий спиннер и «повторите позже» без причины (UX и поддержка страдают молча). Bulkhead: пул соединений/воркеров на сервис (httpx limits per client — отдельный клиент на партнёра; asyncio — semaphore на вызовы; потоки/процессы — GIL-контекст) — медленный сервис B занимает свой пул целиком, сервис A не замечает; таймауты — всегда явные (httpx Timeout(connect=3, read=10) — дефолт «без таймаута» — воркер навсегда — дисциплина).
Практический сценарий: платёжный провайдер клал весь чекаут
Исходник: сбой провайдера платежей 40 минут — наш чекаут: каждый заказ — httpx-вызов без таймаута → воркеры uvicorn зависали на соединениях → 8 воркеров заняты за минуту → весь сайт лёг (не только оплата); после восстановления провайдора — наши же ретраи (тогда — 5 мгновенных попыток в except) добивали его ещё 10 минут. Разбор и фиксы: (1) таймауты — Timeout(connect=3, read=8) на платёжный клиент; (2) retry — tenacity: 3 попытки, random exponential 0.5→8 с, только TransportError/503; POST оплаты — с Idempotency-Key (провайдер поддерживает — дубли исключены — идемпотентность в действии); (3) circuit breaker (pybreaker: fail_max=8, reset_timeout=30) с fallback — «платежи временно недоступны, заказ сохранён, спишем автоматически» (задача в Celery на повтор с минутными backoff-ами — заказ не теряется — надёжность задач); (4) bulkhead — отдельный клиент/пул на платёжку (прочие интеграции не затронуты); (5) метрики: попытки/успехи/состояние breaker'а в Prometheus + алерт «breaker открыт > 2 мин» (метрики). Проверка: учебный сбой (toxiproxy на стейдже — таймауты 100%) — сайт жив (чекаут в режиме «оплатим позже», остальное работает), breaker открылся за 6 запросов, воркеры свободны; восстановление — half-open закрыл цепь за 30 с, очередь отложенных оплат разобралась за 4 минуты без дублей списаний (сверка по idempotency-ключам). Урок зафиксирован в runbook (incident-практика): «ни одного внешнего вызова без таймаута, ретрая-бюджета и breaker'а» — в ревью-чек-лист (чек-лист).
FAQ
Чем backoff с джиттером лучше фиксированной паузы? Синхронизацией: 10 000 клиентов, получивших отказ в один момент, с фиксированной паузой 5 с вернутся одновременно в t+5 — волна нагрузки повторяется ритмично (сервис добивается волнами); джиттер размазывает возвраты по интервалу (равномерное давление — сервис успевает разгребать между подходами); экспонента добавляет «уважение к глубине сбоя» — чем дольше не получается, тем реже стучимся.
Нужен ли circuit breaker для вызовов внутри своего кластера? Между своими сервисами — реже (свои сервисы чинятся деплоем за минуты, retry+таймауты покрывают), но полезен на «дорогих» направлениях: медленные внешние зависимости за вашим сервисом (ваш сервис A зовёт B, B зовёт внешний C — breaker в A против B-таймаутов спасает A), шаренные ресурсы (БД — breaker на пул при её деградации — fast-fail вместо тотального зависания); правило — breaker там, где отказ долгий и лавинообразный, не на каждом httpx-вызове автоматически.
Как ретраить операции с побочными эффектами без идемпотентности сервера? Варианты: локальный журнал («намерение → результат» — перед повтором проверить, не исполнено ли — таблица операций/outbox — паттерн), уникальность по бизнес-ключу (заказ № не создаётся дважды — констрейнт БД поймает повтор — дедуп на своей стороне), «проверить-потом-повторить» (GET состояния перед повторным POST — гонки возможны, но окно узкое); без ничего из этого — повтор вслепую на запись — запрещён (двойные списания/письма — цена выше простоя).
Retry в очереди против retry в синхронном обработчике? Синхронно — для «быстрых переходящих» (1–3 попытки, секунды — пользователь ждёт ответа); длинное/тяжёлое — в очередь с отложенными повторами (минуты-часы, экспонента между прогонами — Celery retry_backoff); гибрид — синхронная попытка → провал → задача в очередь («заказ принят, платёж догоняет») — UX «работает» + надёжность очереди; «10 минут ретраев внутри HTTP-запроса» — архитектурная ошибка (клиент/прокси давно отвалились — воркер жжёт ресурсы для никого).
Источники: AWS Builders Library (timeouts/retries/backoff), документация tenacity, паттерн Circuit Breaker (Microsoft) — ссылки в шапке.