Rate limiting и retry стратегии для LLM API: exponential backoff, circuit breaker, queue

Rate limiting и retry стратегии для LLM API: exponential backoff, circuit breaker, queue

Под нагрузкой LLM API падает. Не «всегда», а в худший момент — когда пользователей больше, чем обычно. Без правильной стратегии 429 («слишком много запросов») и 503 («провайдер перегружен») превращают временный пик в полный отказ сервиса: клиенты ретраят, нагрузка растёт лавиной, очередь разбухает, всё падает. Через единый шлюз provod.ai (Claude Opus 4.7 — 350/1790 ₽, GPT-5.5 — 350/2150 ₽, DeepSeek V4 Pro — 30/60 ₽) ошибки нормализованы под OpenAI-формат — одна и та же стратегия работает для всех моделей, что упрощает переключение между провайдерами при их деградации.

Этот гайд — production-стек защиты: Tenacity для exponential backoff с jitter, circuit breaker для быстрого обхода падшего провайдера, token bucket для собственного rate limiting, конкретные коды и таймауты для всех ключевых ошибок. С работающими примерами кода, конфигами retry policy и метриками для мониторинга. оплата в рублях по договору, полный пакет закрывающих документов.

TL;DR — три слоя защиты

  1. Token bucket на своей стороне — не отправляем больше N RPS, не дожидаясь 429 от провайдера.
  2. Exponential backoff с jitter через Tenacity — на редкие 429/503 ждём 1с±50%, 2с±50%, 4с±50%, 8с±50%, до 3 попыток.
  3. Circuit breaker — если 5 фейлов подряд за 30 сек, открываем «выключатель» на 60 сек и идём в fallback (другой провайдер или кэш). Эта статья — production-расширение нашего pillar-гида полный технический гид по LLM API на Python: токены, function calling, streaming, RAG, async/batch.

429 и 503: что говорят провайдеры

Реальные сообщения от OpenAI, Anthropic и Google:

// OpenAI 429 { "error": { "message": "Rate limit reached for gpt-5-5 in organization org-X on requests per min (RPM): Limit 500, Used 500, Requested 1.", "type": "rate_limit_error", "code": "rate_limit_exceeded" } } // Anthropic 429 (упрощённо) { "type": "error", "error": { "type": "rate_limit_error", "message": "Number of request tokens has exceeded your per-minute rate limit" } } // 503 от любого провайдера { "error": { "message": "The engine is currently overloaded, please try again later.", "type": "server_error", "code": "engine_overloaded" } }

К обоим прикладывается заголовок retry-after в секундах (но не всегда — OpenAI ставит, Anthropic иногда нет). Стратегия: сначала смотрим retry-after, потом считаем свой backoff.

Документация лимитов: OpenAI rate limits, Anthropic rate limits. У provod.ai лимиты выставляются на уровне шлюза и едины для всех моделей за одним ключом — это упрощает планирование емкости.

Rate limiting и retry стратегии для LLM API: exponential backoff, circuit breaker, queue

Tenacity: exponential backoff с jitter за 10 строк

Установка: pip install tenacity.

Минимальный декоратор для LLM-вызова:

from tenacity import ( retry, stop_after_attempt, wait_random_exponential, retry_if_exception_type, ) from openai import OpenAI, APIError, RateLimitError, APITimeoutError import httpx client = OpenAI( api_key="sk-provod-...", base_url="https://api.provod.ai/v1", max_retries=0, # отключаем встроенный retry SDK — делаем сами через tenacity ) @retry( retry=retry_if_exception_type((RateLimitError, APITimeoutError, httpx.HTTPError)), wait=wait_random_exponential(multiplier=1, max=60), stop=stop_after_attempt(5), reraise=True, ) def llm_call(messages: list, model: str = "claude-opus-4-7"): return client.chat.completions.create( model=model, messages=messages, max_tokens=2000, )

Разберём параметры:

  • wait_random_exponential(multiplier=1, max=60) — exponential backoff с jitter. Реальные задержки: 0.5–1с, 1–2с, 2–4с, 4–8с, 8–16с (но не больше 60).
  • stop_after_attempt(5) — 5 попыток включая первую (то есть 4 retry).
  • retry_if_exception_type — ретраим только на эти исключения. На 400/401/403 не ретраим (там APIStatusError, не входит в список).
  • reraise=True — после 5 фейлов поднимаем последнее исключение, а не оборачиваем в RetryError.

Альтернатива — exponential без полного jitter (более предсказуемая):

from tenacity import wait_exponential_jitter @retry( wait=wait_exponential_jitter(initial=1, max=60, jitter=2), stop=stop_after_attempt(5), ) def llm_call_v2(...): ...

Здесь начальная задержка 1с, удваивается до 60, jitter ±2с. Подходит когда вы хотите более предсказуемый минимум.

Уважение к retry-after

Если провайдер вернул retry-after, надо ждать ровно столько, а не своё. Tenacity поддерживает callback на пере-расчёт wait:

from tenacity import retry_if_exception, retry_base from tenacity.wait import wait_base class wait_retry_after(wait_base): """Если RateLimitError содержит retry-after — используем его, иначе fallback.""" def __init__(self, fallback: wait_base): self.fallback = fallback def __call__(self, retry_state): exc = retry_state.outcome.exception if isinstance(exc, RateLimitError) and exc.response is not None: retry_after = exc.response.headers.get("retry-after") if retry_after: try: return float(retry_after) + 0.1 # +100мс на сетевую задержку except ValueError: pass return self.fallback(retry_state) @retry( retry=retry_if_exception_type((RateLimitError, APITimeoutError)), wait=wait_retry_after(wait_random_exponential(multiplier=1, max=60)), stop=stop_after_attempt(5), ) def llm_call_smart(...): ...

Этот паттерн критичен для production: если провайдер сказал «подожди 30 секунд», а вы ретраите через 1с — получите ещё один 429 и быстро попадёте в более строгий лимит.

Rate limiting и retry стратегии для LLM API: exponential backoff, circuit breaker, queue

Token bucket: собственный rate limiting

Tenacity лечит редкие 429, но если вы делаете 100 RPS на лимите 50 RPS — будете получать 429 на каждом втором запросе. Правильно — не доводить до 429, ограничивать себя.

Token bucket алгоритм: ведро на N запросов, пополняется со скоростью R/сек, каждый запрос забирает 1 токен. Простая реализация на asyncio:

import asyncio import time class TokenBucket: def __init__(self, rate: float, capacity: int): """ rate: запросов в секунду (пополнение). capacity: максимальный burst. """ self.rate = rate self.capacity = capacity self.tokens = float(capacity) self.last_refill = time.monotonic self.lock = asyncio.Lock async def acquire(self, tokens: int = 1): async with self.lock: now = time.monotonic elapsed = now - self.last_refill # Пополняем ведро self.tokens = min(self.capacity, self.tokens + elapsed * self.rate) self.last_refill = now # Если токенов мало — ждём if self.tokens < tokens: deficit = tokens - self.tokens wait_time = deficit / self.rate await asyncio.sleep(wait_time) self.tokens = 0 else: self.tokens -= tokens # Usage bucket = TokenBucket(rate=30, capacity=50) # 30 RPS, burst до 50 async def safe_llm_call(messages): await bucket.acquire return await client.chat.completions.create(...)

Для распределённого приложения (несколько worker'ов) — Redis-based bucket через атомарный Lua-скрипт:

import redis import time r = redis.from_url("redis://localhost:6379/2") # Атомарный Lua-скрипт: вычитает 1 токен, пополняет по времени, возвращает успех LUA_TOKEN_BUCKET = """ local key = KEYS[1] local rate = tonumber(ARGV[1]) local capacity = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local requested = tonumber(ARGV[4]) local bucket = redis.call('HMGET', key, 'tokens', 'last_refill') local tokens = tonumber(bucket[1]) or capacity local last_refill = tonumber(bucket[2]) or now local elapsed = now - last_refill tokens = math.min(capacity, tokens + elapsed * rate) if tokens >= requested then tokens = tokens - requested redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now) redis.call('EXPIRE', key, 60) return {1, tokens} else redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now) redis.call('EXPIRE', key, 60) return {0, tokens} end """ # Регистрируем скрипт один раз — Redis считает SHA и потом вызывает по EVALSHA token_bucket_script = r.register_script(LUA_TOKEN_BUCKET) def try_acquire(key: str, rate: float, capacity: int) -> bool: result = token_bucket_script(keys=[key], args=[rate, capacity, time.time, 1]) return bool(result[0]) # Usage while not try_acquire("llm:opus", rate=30, capacity=50): time.sleep(0.1)

Lua-скрипт атомарен в Redis — никакого race condition между несколькими worker'ами. Для русского B2B на 10–50 RPS этого хватит с запасом. Подробнее про асинхронные batch-сценарии — Async и Batch API LLM: 50% скидка.

Circuit breaker: защита от каскадных фейлов

Сценарий: GPT-5.5 «лёг» на 5 минут. Без circuit breaker каждый запрос ждёт 30 секунд таймаута, ретраит 5 раз, итого 5 × 8 = 40 секунд на один запрос. 50 RPS × 40 сек = 2000 зависших задач. Очередь забивается, latency растёт, всё ложится.

Circuit breaker меняет это: после 5 фейлов подряд он «открывается» и следующие 60 секунд сразу отдаёт ошибку (или вызывает fallback), не пытаясь дозвониться. Состояния:

  • closed — нормальная работа, запросы идут.
  • open — фейлов слишком много, все запросы сразу падают/в fallback.
  • half_open — пробный режим: один запрос идёт, успех → closed, фейл → опять open.

Реализация на pure Python (без зависимостей):

import time from enum import Enum from threading import Lock class State(Enum): CLOSED = "closed" OPEN = "open" HALF_OPEN = "half_open" class CircuitBreaker: def __init__(self, fail_threshold: int = 5, recovery_timeout: int = 60): self.fail_threshold = fail_threshold self.recovery_timeout = recovery_timeout self.fail_count = 0 self.last_failure_time = 0 self.state = State.CLOSED self.lock = Lock def call(self, func, *args, **kwargs): with self.lock: if self.state == State.OPEN: if time.monotonic - self.last_failure_time > self.recovery_timeout: self.state = State.HALF_OPEN else: raise CircuitOpenError(f"Circuit open, retry after {self.recovery_timeout}s") try: result = func(*args, **kwargs) except Exception as e: with self.lock: self.fail_count += 1 self.last_failure_time = time.monotonic if self.fail_count >= self.fail_threshold: self.state = State.OPEN raise # Успех with self.lock: if self.state == State.HALF_OPEN: self.state = State.CLOSED self.fail_count = 0 return result class CircuitOpenError(Exception): pass # Usage с fallback opus_breaker = CircuitBreaker(fail_threshold=5, recovery_timeout=60) sonnet_breaker = CircuitBreaker(fail_threshold=5, recovery_timeout=60) def llm_with_fallback(messages): try: return opus_breaker.call( client.chat.completions.create, model="claude-opus-4-7", messages=messages, ) except (CircuitOpenError, Exception): # Fallback на Sonnet 4.6 (210/1070 ₽) return sonnet_breaker.call( client.chat.completions.create, model="claude-sonnet-4-6", messages=messages, )

Для production — библиотеки pybreaker или circuitbreaker с готовыми декораторами и метриками. Через provod.ai fallback между моделями делается одной заменой model="..." — это снижает риск зависимости от одного провайдера. См. Сравнение цен LLM 2026 для выбора недорогих fallback-моделей.

Rate limiting и retry стратегии для LLM API: exponential backoff, circuit breaker, queue

Полный стек: token bucket + Tenacity + circuit breaker

Соединяем всё вместе — production-grade LLM-клиент:

from tenacity import retry, stop_after_attempt, wait_random_exponential, retry_if_exception_type from openai import OpenAI, RateLimitError, APITimeoutError import httpx class ProductionLLMClient: def __init__(self, api_key: str, base_url: str = "https://api.provod.ai/v1"): self.client = OpenAI( api_key=api_key, base_url=base_url, max_retries=0, timeout=httpx.Timeout(connect=5.0, read=120.0, write=10.0, pool=5.0), ) self.bucket = TokenBucket(rate=30, capacity=50) self.breaker = CircuitBreaker(fail_threshold=5, recovery_timeout=60) @retry( retry=retry_if_exception_type((RateLimitError, APITimeoutError, httpx.HTTPError)), wait=wait_retry_after(wait_random_exponential(multiplier=1, max=60)), stop=stop_after_attempt(5), reraise=True, ) async def _raw_call(self, model: str, messages: list, **kwargs): return self.client.chat.completions.create( model=model, messages=messages, **kwargs, ) async def chat(self, model: str, messages: list, fallback_model: str = None, **kwargs): await self.bucket.acquire try: return self.breaker.call(self._raw_call, model, messages, **kwargs) except (CircuitOpenError, Exception) as e: if fallback_model: return await self._raw_call(fallback_model, messages, **kwargs) raise # Usage llm = ProductionLLMClient(api_key="sk-provod-...") response = await llm.chat( model="claude-opus-4-7", # 350/1790 ₽ messages=[{"role": "user", "content": "..."}], fallback_model="claude-sonnet-4-6", # 210/1070 ₽ )

Что здесь работает:

  1. Token bucket не даёт превысить 30 RPS — большая часть 429 не случается.
  2. Tenacity ретраит редкие 429/timeout с jitter, уважает retry-after.
  3. Circuit breaker ловит длительные деградации и быстро уходит в fallback.
  4. Fallback на Sonnet (в 1.7 раз дешевле) — деградация качества вместо полного отказа.

Это базовая структура. Для batch-задач добавьте Async и Batch API LLM с 50% скидкой. Для агентов с tool calling — Function calling tool use. Для отслеживания latency и cost — Логирование и observability LLM.

Метрики для мониторинга

Что собирать в Prometheus:

  • Метрика: llm_requests_total{model,status} • Тип: Counter • Алерт: error_rate > 5% за 5 мин
  • Метрика: llm_request_duration_seconds{model} • Тип: Histogram • Алерт: p99 > 60 сек
  • Метрика: llm_retries_total{model,attempt} • Тип: Counter • Алерт: retry_rate > 20% — провайдер деградирует
  • Метрика: llm_token_bucket_wait_seconds{model} • Тип: Histogram • Алерт: p95 > 2 сек — нужно увеличить лимит
  • Метрика: llm_circuit_breaker_state{model} • Тип: Gauge • Алерт: state=open > 30 сек — алерт оператору
  • Метрика: llm_fallback_total{from_model,to_model} • Тип: Counter • Алерт: резкий рост — фейл primary провайдера
  • Метрика: llm_429_total{model} • Тип: Counter • Алерт: рост — token bucket не справляется

Точные пороги зависят от вашей нагрузки. Снимайте baseline в первую неделю, потом настраивайте алерты на отклонения. См. также B2B-чеклист 12 вопросов поставщику LLM API — раздел про SLA и согласованные RPM/TPM лимиты.

Антипаттерны: чего не делать

  • Retry на 4xx ошибках (кроме 429). 400/401/403/422 не ретраятся — это баги в коде. Каждая попытка тратит лимит и деньги.
  • Бесконечный retry без stop_after_attempt. Пользователь будет ждать вечность, очередь забьётся.
  • Retry без jitter. 100 клиентов после 429 ретраят в одну миллисекунду — снова 429. Thundering herd.
  • Игнорировать retry-after. Если провайдер сказал 30с — он знает что говорит. Ретрай через 1с попадёт в более жёсткий лимит.
  • Circuit breaker без fallback. Если просто бросать CircuitOpenError, клиент получит фейл. Имеет смысл только когда есть план B (другая модель, кэш, деградированный режим).
  • Token bucket с capacity=rate. Bucket нужен для burst — если capacity = rate, вы не получаете преимущества над fixed-window лимитом.
  • Один circuit breaker на все модели. Если Opus упал, Sonnet ещё работает. Нужен per-model breaker.
  • Retry на тимауте без сохранения partial state. Streaming-ответ уже частично оплачен — сохраняйте и используйте, не теряйте.
Rate limiting и retry стратегии для LLM API: exponential backoff, circuit breaker, queue

Production-чеклист

  • [ ] Token bucket на своей стороне, rate = 80% от лимита провайдера.
  • [ ] Tenacity с wait_random_exponential(max=60) и stop_after_attempt(5).
  • [ ] Кастомный wait для уважения retry-after хедера.
  • [ ] retry_if_exception_type только на 429/5xx/network. Никаких 4xx.
  • [ ] Circuit breaker per-model с recovery_timeout=60 сек.
  • [ ] Fallback на дешёвую модель (Sonnet вместо Opus, GPT-5.4 вместо GPT-5.5).
  • [ ] timeout на HTTP-клиент: connect 5, read 120, write 10, pool 5.
  • [ ] max_retries=0 в SDK — все ретраи через Tenacity, не дублировать.
  • [ ] Метрики Prometheus: requests_total, duration, retries, 429, bucket_wait, breaker_state.
  • [ ] Алерты: error_rate > 5%, retry_rate > 20%, breaker_state=open > 30 сек.
  • [ ] Логи retry с trace_id для отслеживания флапов.
  • [ ] Распределённый bucket через Redis Lua для multi-worker setup.

Через provod.ai fallback между моделями делается одной заменой model="...". Цены прозрачны 1-в-1 с провайдером по курсу ЦБ — для дешёвых fallback подходят DeepSeek V4 Pro (30/60 ₽) и Qwen 3.6 Plus (20/130 ₽). Полное руководство по выбору модели для конкретной задачи — в Сравнении цен LLM 2026.

Rate limiting и retry стратегии для LLM API: exponential backoff, circuit breaker, queue

FAQ

Чем отличаются rate limit, retry и circuit breaker?

Rate limit — собственный лимит запросов (не больше N RPS). Retry — попытки повтора при временной ошибке (429/503/timeout). Circuit breaker — защита от каскадных фейлов: если провайдер «лёг», временно перестаём ходить и идём в fallback. Три слоя дополняют, не заменяют друг друга.

Почему jitter обязателен?

Без jitter все клиенты после 429 ретраят ровно через 1, 2, 4 секунды — снова создают пик. Thundering herd. Jitter — случайная добавка ±25–50% размазывает нагрузку. Tenacity делает через wait_random_exponential. Без jitter retry лечит одного клиента, но ломает 100.

Какие ошибки retry'ить?

Retry: 429, 500/502/503/504, сетевые (ConnectionError, Timeout). НЕ retry: 400, 401, 403, 404, 422, 413, content_filter. На 429 — обязательно читать retry-after хедер и использовать вместо своего backoff.

Что такое token bucket?

Алгоритм собственного rate limiting: ведро на N токенов, пополняется со скоростью R/сек, каждый вызов забирает 1. Если пусто — ждём. Даёт burst-friendly поведение: 30 быстрых запросов подряд, потом ожидание. Нужен когда несколько типов задач делят один лимит провайдера.

Когда нужен circuit breaker?

Когда есть fallback-сценарий (другой провайдер, кэш, деградированный режим). Если Opus 4.7 не отвечает 30 сек, circuit открывается, следующие 60 сек запросы идут в fallback без попыток дозвониться. Без breaker один сбой утопит весь сервис.

Tenacity или backoff?

Tenacity — более популярна, активно поддерживается, гибче. backoff проще, но менее гибка. Для production — Tenacity: больше функций, лучше asyncio интеграция, легко комбинируется с circuit breaker. pip install tenacity без зависимостей.

provod.ai — российский LLM API-агрегатор

Один OpenAI-совместимый endpoint ко всем флагманам: OpenAI (GPT-5.5, GPT-5.4), Anthropic (Claude Opus 4.7, Sonnet 4.6), Google (Gemini 3.1 Pro, 3.5 Flash), DeepSeek V4 Pro, Qwen 3.6 Plus.

Цены 1-в-1 с провайдером по курсу ЦБ — без наценки на токены. Оплата в рублях по договору, полный пакет закрывающих документов (договор-оферта, счёт, акт, счёт-фактура, УПД 5.03 через ЭДО). Без VPN — легальный B2B-сервис в России.

Если статья была полезной — попробуйте provod.ai: главная страница · каталог моделей · документация