promptra
← Все статьи
Гайды7 мин чтения

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

Production-гайд 2026 для Python: rate limiting и retry стратегии для LLM API. Tenacity с exponential backoff и jitter, circuit breaker pattern, token bucket для собственных лимитов, обработка 429 от OpenAI/Anthropic/Google, точные конфиги для Opus 4.7, GPT-5.5 и DeepSeek V4 Pro.

Инфографика retry-стратегии LLM API: HTTP 429 ответ, экспоненциальный backoff с jitter 1с-2с-4с-8с, circuit breaker открыт/полуоткрыт/закрыт, token bucket с пополнением; плоский векторный стиль в кремово-терракотовой палитре

Под нагрузкой 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 — три слоя защиты

  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. У Promptra лимиты выставляются на уровне шлюза и едины для всех моделей за одним ключом — это упрощает планирование емкости.

Схема трёх типов ошибок LLM API: 429 rate limit с заголовком retry-after, 503 engine overloaded, network timeout/ReadError; каждый блок с цветом важности и стрелкой к правильному ответу retry / wait / circuit; заголовок «Какие ошибки требуют retry»

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 и быстро попадёте в более строгий лимит.

Диаграмма exponential backoff с jitter: горизонтальная ось времени, 5 точек попыток на 0с, 1с, 2с, 4с, 8с, для каждой попытки серый горизонтальный диапазон jitter ±50%; терракотовая выноска «retry-after: 30s» поверх 3-й попытки переопределяет backoff; заголовок «Backoff с уважением к retry-after»

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-моделей.

Диаграмма состояний circuit breaker: три круглых блока closed-open-half_open соединённые стрелками с подписями условий: closed→open «5 фейлов за 30с», open→half_open «через 60с recovery», half_open→closed «успешный пробный запрос», half_open→open «фейл пробного»; заголовок «Состояния circuit breaker»

Полный стек: 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 ₽
)

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

  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}Countererror_rate > 5% за 5 мин
llm_request_duration_seconds{model}Histogramp99 > 60 сек
llm_retries_total{model,attempt}Counterretry_rate > 20% — провайдер деградирует
llm_token_bucket_wait_seconds{model}Histogramp95 > 2 сек — нужно увеличить лимит
llm_circuit_breaker_state{model}Gaugestate=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-ответ уже частично оплачен — сохраняйте и используйте, не теряйте.
Чек-лист антипаттернов: 8 красных карточек с типичными ошибками — «retry на 4xx», «без jitter», «без stop», «игнор retry-after», «без fallback», «общий breaker», «бесконечные попытки», «потеря partial state»; зелёная строка снизу «правильно: layered defence»; заголовок «8 ошибок retry-стратегии»

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.

Финальная инфографика production-стека: 3 концентрических круга — внешний «Token bucket (предотвращение 429)», средний «Tenacity retry (лечение редких 429)», внутренний «Circuit breaker (защита от деградации)»; в центре «LLM API»; стрелки fallback ведут к второй модели; заголовок «3 слоя защиты LLM-вызовов»

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