Проблема: временные сбои неизбежны

Агент — это цепочка сетевых вызовов: к LLM-провайдеру, к инструментам, к БД, к внешним API. В распределённых системах каждый из них периодически даёт временный сбой — не потому что что-то сломано навсегда, а потому что «прямо сейчас» перегрузка, дедлайн, моргнула сеть. Это transient (переходящие) ошибки, и они — норма, а не исключение.

Типичные transient-сбои у агента:

  • 429 Too Many Requests — упёрлись в rate limit провайдера (об этом отдельный урок).
  • 5xx (500, 502, 503, 504) — провайдер временно перегружен/недоступен.
  • Таймауты и сетевые сбои — соединение оборвалось, ответ не пришёл вовремя.

Ключевое наблюдение: почти все они проходят сами через доли секунды. Уронить из-за мгновенного 503 весь запрос пользователя — глупо, когда повтор через 200 мс вернёт успех. Retry — простейший и самый эффективный механизм надёжности, первая линия обороны.

ℹ️ Retry лечит временное, но не постоянное

Retry работает ровно для transient-сбоев — тех, что исчезают сами. Повторять то, что сломано детерминированно (неверный API-ключ, кривой запрос, баг в коде), бессмысленно: сколько ни повторяй — будет та же ошибка, только медленнее и дороже. Поэтому центральный вопрос ретрая — не «как повторить», а «что вообще стоит повторять». К нему вернёмся ниже.

Почему backoff экспоненциальный

Первое желание — повторять сразу и часто: «упало → тут же пробуем снова». Это худшее, что можно сделать. Если провайдер упал под нагрузкой, а тысячи клиентов мгновенно и непрерывно ретраят — они создают лавину запросов, которая не даёт ему подняться. Retry без задержки превращает временный сбой в постоянный. Это называют retry storm / thundering herd.

Решение — backoff: пауза между попытками, которая растёт с каждой неудачей. И расти она должна экспоненциально, а не линейно:

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
Как растёт пауза между попытками ❌ Без задержки — retry storm мгновенно, лавиной → добивает провайдер 🟡 Линейный (1,2,3,4с) — растёт слишком медленно 1с 2с 3с 4с ✓ Экспоненциальный (1,2,4,8с) — быстро разгружает 1с 2с 4с 8с задержка = base × 2^попытка ✓✓ + Jitter — случайный разброс, чтобы клиенты не синхронились ≈8с ± случайность → нагрузка размазана во времени экспонента даёт провайдеру время встать · jitter не даёт всем повторить в один момент

Формула проста: задержка = base × 2^(номер попытки), обычно с потолком (cap). Экспонента важна тем, что она быстро отступает: если провайдер серьёзно перегружен, паузы 1→2→4→8 с дают ему реальное время подняться, тогда как линейный рост (1→2→3→4) отступает лениво и продолжает давить.

⚠️ Без jitter экспонента синхронизирует клиентов

Тонкость: если 1000 клиентов упали в один момент и все ждут ровно 1, 2, 4, 8 с — они синхронно ударят снова в одни и те же моменты, воспроизводя мини-шторм на каждом шаге. Лечение — jitter: добавить к задержке случайную составляющую (например, ждать случайное время от 0 до вычисленного backoff). Тогда повторы «размазываются» во времени, и нагрузка на провайдера ровная. Exponential backoff без jitter — незаконченный ретрай.

Что ретраить, а что нет

Самое важное решение — не «как повторять», а «что повторять». Повторять надо только сбои, у которых есть шанс пройти сами; повтор детерминированной ошибки — трата времени, денег и, хуже, риск (см. идемпотентность ниже).

ОшибкаРетраить?Почему
429 rate limit✅ да (с backoff)Пройдёт, часто есть Retry-After
500 / 502 / 503 / 504✅ даВременная перегрузка провайдера
Таймаут / сетевой сбой✅ да (осторожно)Часто transient; но см. идемпотентность
401 / 403 auth❌ нетКривой ключ/права — повтор не поможет
400 bad request❌ нетНекорректный запрос — детерминированно упадёт снова
422 / ошибка валидации❌ нетПроблема во входных данных, а не в сервисе
❌ Не ретрай «всё подряд»

Слепой retry(any_exception) — антипаттерн: ты будешь пять раз повторять неверный API-ключ (401), кривой промпт (400), баг в коде — тратя секунды и токены на заведомо провальные попытки, и маскируя настоящую проблему. Ретрай — только для явно transient-ошибок (429, 5xx, сетевые). Всё остальное должно падать сразу, чтобы ты увидел настоящую причину.

Идемпотентность: скрытая ловушка

Есть коварный случай: запрос дошёл и выполнился, но ответ потерялся по дороге (таймаут на обратном пути). Клиент думает «сбой» и повторяет — а действие выполняется дважды. Для чтения это безобидно, для действий с побочными эффектами — катастрофа.

Идемпотентность — свойство операции давать тот же результат при повторе. Прежде чем ретраить, спроси: безопасно ли повторить это действие?

  • Идемпотентно (повторять безопасно): чтение, поиск, вызов LLM-генерации, GET-запросы — повтор просто даёт ещё один ответ.
  • Не идемпотентно (повтор опасен): списание денег, отправка письма, создание заказа, запись в БД — повтор = двойное действие.
⚠️ Для агентов это про инструменты с побочными эффектами

Вызовы LLM обычно безопасно ретраить (повторная генерация — не беда). Опасность — в инструментах, меняющих мир: агент вызвал «оформить возврат», получил таймаут, повторил — оформил два возврата. Защита: делать такие операции идемпотентными на стороне API (idempotency key — уникальный ключ операции, по которому сервер отсекает дубли) и/или не ретраить неидемпотентные вызовы вслепую. Ретрай безопасен ровно настолько, насколько идемпотентна операция.

Практика: retry на tenacity

Писать backoff+jitter руками не нужно — есть библиотеки (в Python популярна tenacity; SDK OpenAI/Anthropic уже содержат встроенный ретрай на transient-ошибки). Ключевые параметры, которые ты настраиваешь: какие ошибки ретраить, стратегия задержки, лимит попыток.

Exponential backoff + jitter с tenacity
python
from tenacity import (retry, stop_after_attempt, wait_exponential_jitter,
                      retry_if_exception_type)
import httpx

class TransientError(Exception): ...   # 429 / 5xx / сеть — то, что ретраим

@retry(
    retry=retry_if_exception_type(TransientError),   # ТОЛЬКО transient!
    wait=wait_exponential_jitter(initial=1, max=30),  # 1,2,4,8… + jitter, потолок 30с
    stop=stop_after_attempt(5),                       # не больше 5 попыток
    reraise=True,                                     # исчерпали → пробрасываем ошибку
)
def call_llm(prompt: str) -> str:
    resp = httpx.post(LLM_URL, json={"prompt": prompt}, timeout=30)
    if resp.status_code == 429 or resp.status_code >= 500:
        raise TransientError(resp.status_code)   # ретраим
    resp.raise_for_status()                      # 4xx (400/401) → падаем СРАЗУ, без ретрая
    return resp.json()["text"]

Обрати внимание на три решения в коде: ретраим только TransientError (не всё подряд), задержка экспоненциальная с jitter и с потолком, и есть жёсткий лимит попыток. 4xx-ошибки поднимаются сразу, без повторов.

✅ Уважай Retry-After

При 429 (и иногда 503) провайдер часто присылает заголовок Retry-After — сколько ждать. Если он есть — используй его вместо своего вычисленного backoff: сервер лучше знает, когда будет готов. Хорошие SDK делают это автоматически. Игнорировать Retry-After и долбить по своему графику — способ снова упереться в лимит.

Когда остановиться

Ретрай не бесконечен. Повторять вечно — значит держать пользователя в ожидании и жечь ресурсы на сервис, который, возможно, лёг надолго. Границы:

  • Лимит попыток (например, 3–5) или общий бюджет времени — что наступит раньше. Исчерпали → это уже не transient, поднимаем ошибку выше.
  • Что дальше после исчерпания? Ретрай — не финал, а первый уровень. Не помогло → включается fallback (запасной провайдер/модель — следующий урок) или graceful degradation (частичный ответ — урок в конце раздела).
  • Circuit breaker. Если провайдер стабильно падает, умные системы перестают его дёргать на время («размыкают цепь»), чтобы не тратить попытки впустую и дать ему восстановиться, — а потом осторожно пробуют снова.
🔌 Circuit breaker — «предохранитель» поверх ретрая

Ретрай борется с единичным сбоем. Circuit breaker — с устойчивым: он считает ошибки и, когда их слишком много, «размыкается» — на время возвращает ошибку мгновенно, не нагружая упавший сервис бесполезными попытками. Через паузу пробует один запрос («half-open»): ок → «замыкается» обратно. Это защищает и провайдера (не добиваем), и тебя (не ждём по 30 с на каждый запрос, когда сервис явно лёг). Ретрай + circuit breaker — стандартная связка надёжности.

Типичные ошибки

❌ Ошибка 1: ретрай без задержки

Мгновенные повторы создают retry storm и добивают упавший провайдер. Всегда используй backoff — и именно экспоненциальный.

❌ Ошибка 2: exponential без jitter

Без случайного разброса клиенты синхронно бьют в одни моменты, воспроизводя шторм. Джиттер обязателен.

❌ Ошибка 3: ретраить всё подряд

Повтор 401/400/багов — трата времени и денег, маскировка настоящей проблемы. Ретрай только transient (429/5xx/сеть).

❌ Ошибка 4: слепой ретрай неидемпотентных действий

Повтор «оплаты»/«создать заказ» после таймаута может выполнить действие дважды. Делай такие операции идемпотентными (idempotency key) или не ретрай вслепую.

❌ Ошибка 5: бесконечный ретрай

Без лимита попыток/бюджета времени пользователь ждёт вечно. Ограничь, а после исчерпания переходи к fallback / деградации.

Шпаргалка

Retry + backoff — всё в одном месте
text
RETRY = первая линия обороны против TRANSIENT-сбоев (временных, проходят сами)
  transient: 429 · 5xx · таймаут/сеть → повтор часто спасает
  лечит ВРЕМЕННОЕ, НЕ постоянное (401/400/баг → повтор бесполезен)

BACKOFF (пауза между попытками, РАСТЁТ):
  ❌ без задержки → retry storm (добивает провайдер)
  ✓ ЭКСПОНЕНЦИАЛЬНЫЙ: delay = base × 2^attempt + CAP (1,2,4,8…30с)
    быстро отступает → даёт провайдеру встать
  ✓✓ + JITTER (случайный разброс) — иначе клиенты СИНХРОННО бьют = мини-шторм
  exponential БЕЗ jitter = незаконченный ретрай

ЧТО РЕТРАИТЬ: 429/5xx/сеть = ДА · 401/403/400/422 = НЕТ (детерминированны → падать сразу)
  ❌ retry(any_exception) — антипаттерн

ИДЕМПОТЕНТНОСТЬ (безопасно ли повторить?):
  ✓ чтение/поиск/LLM-генерация — да · ✗ оплата/письмо/заказ/запись — ДВОЙНОЕ действие
  ответ потерялся, а действие ВЫПОЛНИЛОСЬ → повтор дублирует
  защита: idempotency key на API · не ретрай неидемпотентное вслепую

ПРАКТИКА: tenacity / встроенный ретрай SDK · уважай заголовок Retry-After (429/503)

КОГДА СТОП: лимит попыток (3–5) ИЛИ бюджет времени → дальше FALLBACK / деградация
  CIRCUIT BREAKER: много ошибок → «размыкается», мгновенный отказ, не добивает сервис
    → через паузу half-open пробует · ретрай (единичный сбой) + breaker (устойчивый)

ГРАБЛИ: без задержки · без jitter · ретрай всего · неидемпотентное вслепую · бесконечно

Практическое задание

Задание: добавь надёжный ретрай агенту

  1. Для агента из модулей 03–05 выпиши все сетевые вызовы (LLM, инструменты, БД). Какие из них могут дать transient-сбой?
  2. Для каждого типа ошибки (429, 500, 503, таймаут, 401, 400) реши: ретраить или нет? Составь таблицу-политику.
  3. Оберни вызов LLM в exponential backoff + jitter (tenacity или вручную) с лимитом 5 попыток. Проверь, что 4xx падает сразу, а 5xx повторяется.
  4. Найди среди инструментов агента неидемпотентные (с побочными эффектами). Что случится при их слепом ретрае после таймаута? Как защитить (idempotency key)?
  5. Смоделируй retry storm: почему 1000 клиентов с ретраем без jitter хуже, чем с jitter? Опиши на пальцах.
  6. Что твой агент делает, когда все 5 попыток исчерпаны? (Пока — просто зафиксируй; ответ — в следующих уроках про fallback и деградацию.)
  7. Со звёздочкой: добавь простой circuit breaker: после N ошибок подряд перестань дёргать провайдер на T секунд, потом пробуй один запрос. Как это меняет поведение при долгом падении?

Что дальше

Ретрай спасает от мгновенных сбоев. Но что делать, когда провайдер лёг надолго и повторы исчерпаны? Не падать же совсем. Ответ — переключиться на запасной вариант. Следующий урок — fallback-стратегии: запасные провайдеры и модели, деградация цепочкой, чтобы агент продолжал работать при отказе основного LLM.