Под нагрузкой LLM API падает. Не «всегда», а в худший момент — когда пользователей больше, чем обычно. Без правильной стратегии 429 («слишком много запросов») и 503 («провайдер перегружен») превращают временный пик в полный отказ сервиса: клиенты ретраят, нагрузка растёт лавиной, очередь разбухает, всё падает. Через единый шлюз Promptra (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 — три слоя защиты
- Token bucket на своей стороне — не отправляем больше N RPS, не дожидаясь 429 от провайдера.
- Exponential backoff с jitter через Tenacity — на редкие 429/503 ждём 1с±50%, 2с±50%, 4с±50%, 8с±50%, до 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. У Promptra лимиты выставляются на уровне шлюза и едины для всех моделей за одним ключом — это упрощает планирование емкости.

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-promptra-...",
base_url="https://api.promptra.ru/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 и быстро попадёте в более строгий лимит.

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 с готовыми декораторами и метриками. Через Promptra fallback между моделями делается одной заменой model="..." — это снижает риск зависимости от одного провайдера. См. Сравнение цен LLM 2026 для выбора недорогих fallback-моделей.

Полный стек: 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.promptra.ru/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-promptra-...")
response = await llm.chat(
model="claude-opus-4-7", # 350/1790 ₽
messages=[{"role": "user", "content": "..."}],
fallback_model="claude-sonnet-4-6", # 210/1070 ₽
)Что здесь работает:
- Token bucket не даёт превысить 30 RPS — большая часть 429 не случается.
- Tenacity ретраит редкие 429/timeout с jitter, уважает retry-after.
- Circuit breaker ловит длительные деградации и быстро уходит в fallback.
- 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-ответ уже частично оплачен — сохраняйте и используйте, не теряйте.

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

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 без зависимостей.
