API Rate Limiting429 Error HandlingProduction Reliability

Как обрабатывать лимиты LLM API и ошибки 429 в продакшне

1 мин чтения

429 Too Many Requests — самый полезный ответ, который отправляет ваш LLM-провайдер. Полезнее тела JSON. Действеннее кода ошибки. Потому что в его заголовках спрятан измеритель ёмкости в реальном времени — x-ratelimit-remaining, retry-after — который большинство продакшн-кода игнорирует. Инженеры относятся к лимитам запросов как к стене, о которую разбиваются. Это измеритель, который нужно читать.

Вот как выглядит столкновение со стеной. Ваше приложение отлично работает в 14:00. Трафик растёт к 15:00. К 15:15 каждый запрос возвращает 429. Ваша логика повторных попыток срабатывает — фиксированные интервалы в одну секунду — и создаёт табун (thundering herd). Каждый повтор попадает в то же окно лимита. Десять минут каскадных сбоев. Пользователи видят ошибки. Дежурного поднимают по пейджеру. Исправлением было не «повторять усерднее». Это никогда не было «повторять усерднее».

Разрыв между этими двумя исходами — разбиться о стену против чтения измерителя — это три слоя кода. Эта статья охватывает все три. Реактивный слой: экспоненциальный бэкофф с джиттером. Проактивный слой: троттлинг с учётом заголовков, который читает оставшийся бюджет и замедляется до столкновения со стеной. Предиктивный слой: приостановка до вызова, которая делает контрольную точку состояния агента до того, как следующий вызов исчерпает квоту. Рабочий код Python для каждого слоя. Проверенные в продакшне паттерны.

Фундаментальные концепции аутентификации API и управления ключами, лежащие в основе обработки лимитов, смотрите в нашем руководстве по безопасности API-ключей.

Почему существуют лимиты запросов — и как они на самом деле работают

Лимиты запросов — не наказание. Это защита инфраструктуры. Каждый запрос к API потребляет память GPU и вычисления. Неограниченный клиент может насытить инференс-кластер провайдера за секунды. Лимиты обеспечивают справедливое распределение между всеми пользователями.

Три лимита, о которых нужно заботиться:

  • RPM (Requests Per Minute): сколько вызовов API вы можете сделать. Тарифы с оплатой по мере использования: обычно 500–3,000 RPM. Бесплатные: 10–50 RPM. Корпоративные: индивидуально.
  • TPM (Tokens Per Minute): суммарные токены всех запросов — вход + выход. Один промпт на 100K токенов потребляет столько же квоты, сколько 500 обычных запросов. Ограничение TPM защищает от этого.
  • Параллельные запросы: сколько запросов может быть в полёте одновременно. Превысите — новые запросы встают в очередь или отклоняются. Этот лимит часто не задокументирован и познаётся через болезненный опыт.

Тарифы конкретных провайдеров дают этим лимитам реальные числа. Tier 5 OpenAI (высший уровень с оплатой по мере использования) даёт 10,000 RPM и 30,000,000 TPM для моделей GPT-4.x — но Tier 1 начинается всего с 500 RPM и 200,000 TPM. Claude API от Anthropic предлагает 1,000 RPM на стандартном тарифе с 80,000 TPM для Claude Opus и 400,000 TPM для Claude Sonnet, отражая разную стоимость инференса на модель.

Gemini API от Google даёт 1,500 RPM с оплатой по мере использования при потолке 2,000,000 TPM. Каждый провайдер также применяет переопределения на модель — лимит TPM Claude Opus 4 жёстче, чем Claude Sonnet 4, потому что более крупные модели потребляют пропорционально больше вычислений. Переход с Tier 1 на Tier 5 в OpenAI требует и повышенной истории расходов (от $250 в месяц), и подтверждённого опыта незлоупотребительного использования за 30+ дней.

Высокие лимиты не получают вежливой просьбой — их зарабатывают устойчивыми паттернами продакшн-трафика, доказывающими, что вы не зальёте кластер. Знать свой точный тариф и его лимиты — первый шаг к построению любого из трёх последующих слоёв.

Как читать свои текущие лимиты. Каждый ответ API содержит заголовки лимитов — и почти никто их не читает. Документация OpenAI по лимитам объясняет формат заголовков и структуру тарифов:

x-ratelimit-remaining-requests: 487
x-ratelimit-remaining-tokens: 823000
x-ratelimit-reset-requests: 12s

Они приходят на 200-ответах, а не только на 429. Они точно говорят, сколько бюджета осталось до троттлинга. Выведите их как измеритель на дашборде мониторинга. Срабатывайте при падении остатка ниже 20%. Разница между «мы упёрлись в лимит» и «мы видели его приближение и обошли» — это чтение этих заголовков.

Истории ужасов с лимитами: два инцидента, которые не хотите повторять

Европейская e-commerce платформа запустила AI-ассистента для шопинга на чёрную пятницу на базе GPT-4.5. QA тестировал при 50 параллельных пользователях — продакшн за первый час достиг 2,300. Повторные попытки с фиксированным интервалом превратили 429 в 47-минутный сбой.

Потеря выручки: $180,000 в отслеженных брошенных корзинах за это окно. Корневая причина — не объём трафика, а логика повторов, усилившая всплеск вместо его поглощения.

SaaS-аналитическая компания мигрировала между версиями API OpenAI, не читая журнал изменений лимитов. Новая версия уполовинила их RPM с 3,000 до 1,500 на их тарифе. Существующий код троттлинга предполагал старый лимит.

Продакшен работал шесть дней — пока месячный цикл отчётности не утроил объём запросов. Каждое задание отчёта одновременно получило 429. Обнаружение заняло 22 минуты, потому что их мониторинг отслеживал только ошибки 5xx, а не 429.

Исправление заняло три строки: обновить константу RPM. Урок был постоянным: каждая миграция версии API — это миграция лимитов.

Слой 1: клиентское троттлингование

Самый простой слой. Ведро токенов или семафор, не позволяющие вашему приложению превысить заявленные провайдером лимиты.

import asyncio
import time

class RateLimiter:
    """Token bucket rate limiter for LLM API calls."""

    def __init__(self, max_rpm: int):
        self.max_rpm = max_rpm
        self.tokens = max_rpm
        self.last_refill = time.monotonic()
        self.semaphore = asyncio.Semaphore(max_rpm // 6)  # Concurrency cap

    async def acquire(self):
        """Wait until a request can be sent without exceeding RPM."""
        # Refill tokens based on elapsed time
        now = time.monotonic()
        elapsed = now - self.last_refill
        refill = elapsed * (self.max_rpm / 60)
        self.tokens = min(self.max_rpm, self.tokens + refill)
        self.last_refill = now

        if self.tokens < 1:
            wait_time = (1 - self.tokens) / (self.max_rpm / 60)
            await asyncio.sleep(wait_time)
            self.tokens = 1

        self.tokens -= 1

limiter = RateLimiter(max_rpm=500)

async def rate_limited_api_call(model: str, messages: list):
    await limiter.acquire()
    # Make the API call

Важный порядок: всегда получайте токен RPM до семафора параллельности. Обратный порядок вызывает блокировку головы очереди — слоты параллельности заполняются запросами, которые не могут отправиться, оставляя запросы, которые могут, без обслуживания.

Этот слой предотвращает самую распространённую самонанесённую рану лимитов: превышение заявленного лимита вашего тарифа, потому что вы не отслеживали скорость вызовов. Для большинства приложений с малым и средним трафиком этого достаточно.

Слой 2: бэкофф с учётом заголовков

Слой 1 предотвращает превышение известных лимитов. Слой 2 разбирается с тем, что происходит, когда фактическая ёмкость провайдера колеблется — а она колеблется постоянно, в зависимости от общей нагрузки кластера.

import random
from openai import OpenAI

client = OpenAI(
    base_url="https://api.tokspan.com/v1",
    api_key="ts-your-key-here"
)

def chat_with_backoff(messages, model="claude-opus-4-8", max_retries=4):
    """Exponential backoff with jitter + header awareness."""
    for attempt in range(max_retries):
        try:
            response = client.chat.completions.create(
                model=model,
                messages=messages,
                timeout=30
            )
            # Read remaining budget from headers —even on success
            remaining = response.headers.get("x-ratelimit-remaining-requests")
            if remaining and int(remaining) < 50:
                print(f"Rate limit low: {remaining} requests remaining. Slow down.")
            return response

        except Exception as e:
            if "429" in str(e) or "rate_limit" in str(e).lower():
                if attempt == max_retries - 1:
                    raise  # Out of retries
                # Exponential backoff: 1s —2s —4s —8s
                wait = (2 ** attempt) + random.uniform(0, 1)
                print(f"Rate limited. Retrying in {wait:.1f}s (attempt {attempt + 1}/{max_retries})")
                import time
                time.sleep(wait)
            else:
                raise  # Not a rate limit error —don't retry

Три правила бэкоффа:

  1. Никогда не повторяйте с фиксированным интервалом. Это создаёт табун. Каждый клиент, повторяющий через t+1с, попадает в то же окно лимита.
  2. Всегда добавляйте джиттер. + random.uniform(0, 1) распределяет повторы по окну. Только это предотвращает большинство каскадных сбоев.
  3. Никогда не повторяйте 401, 403 или 400. Повтор неверного ключа или некорректного запроса не исправит их. Повторяйте только 429 и 5xx.

Чего НЕ делать. Этот паттерн — на удивление часто встречающийся в продакшн-коде — эквивалент крика на человека, который не говорит на вашем языке:

# DO NOT DO THIS
while True:
    try:
        response = client.chat.completions.create(...)
        break
    except:
        time.sleep(1)  # Fixed interval, no jitter, infinite retry

Это создаёт табун и гарантирует, что вы останетесь под лимитом. Каждый повтор приходит в точно ту же точку окна лимита. Инфраструктура провайдера видит всплеск идентичных запросов, троттлит их все, и ваше приложение входит в спираль смерти.

Слой 3: предиктивная приостановка

Слои 1 и 2 реактивны — они отвечают после попадания (или приближения) к лимиту. Слой 3 предиктивен — он читает бюджет до вызова и решает: продолжить, подождать или сделать контрольную точку и приостановиться.

def predict_rate_limit(response_headers: dict) -> str:
    """Three-valued decision based on remaining budget."""
    remaining_req = int(response_headers.get("x-ratelimit-remaining-requests", 1000))
    remaining_tok = int(response_headers.get("x-ratelimit-remaining-tokens", 1000000))

    if remaining_req > 100 and remaining_tok > 200000:
        return "continue"      # Plenty of budget
    elif remaining_req > 20:
        return "wait"          # Budget running low —short pause
    else:
        return "checkpoint"    # Budget nearly exhausted —suspend

# Usage in an agent loop:
for step in agent_steps:
    response = call_llm(current_state)
    decision = predict_rate_limit(response.headers)

    if decision == "continue":
        process(response)
    elif decision == "wait":
        time.sleep(5)  # Short pause, let budget recover
        process(response)
    else:  # checkpoint
        save_agent_state(current_state)  # Save progress
        time.sleep(60)  # Wait for rate-limit window reset
        resume_agent_from_checkpoint()  # Resume without losing work

Состояние искусства 2026 года: agentpause. Библиотека Python, которая читает заголовки лимитов на каждом ответе, предсказывает исчерпание до следующего вызова и делает контрольную точку состояния агента перед приостановкой. Измеренные результаты: 0% крашей против 100% реактивного базового уровня. Ноль ошибок 429. На 80% меньше отходов токенов от неудачных повторов. Если вы запускаете высоконагруженных продакшн-агентов, agentpause или эквивалентная предиктивная логика больше не опциональны — это разница между «наши агенты надёжны» и «наши агенты случайно падают при скачке трафика».

Маршрутизация по нескольким провайдерам: лучшее решение для лимитов

Каждая стратегия выше предполагает общение с одним провайдером. Самая эффективная стратегия лимитов — общение с несколькими.

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

Раунд-робин маршрутизатор с ведрами токенов на провайдера и автоматическим кулдауном:

PROVIDERS = {
    "openai": {"rpm": 2000, "cooldown_until": 0},
    "anthropic": {"rpm": 1500, "cooldown_until": 0},
    "google": {"rpm": 1000, "cooldown_until": 0},
}

def route_request(messages):
    now = time.time()
    available = [
        p for p, cfg in PROVIDERS.items()
        if now > cfg["cooldown_until"]
    ]
    if not available:
        raise RuntimeError("All providers in cooldown")

    # Round-robin among available providers
    provider = available[hash(str(messages)) % len(available)]
    try:
        return call_provider(provider, messages)
    except RateLimitError:
        PROVIDERS[provider]["cooldown_until"] = now + 30  # Cooldown for 30s
        return route_request(messages)  # Retry with a different provider

Агрегационные платформы обрабатывают это на уровне инфраструктуры — ваш единственный endpoint маршрутизирует ко всем провайдерам, с автоматическим кулдауном, отказоустойчивостью и мониторингом лимитов. Вы задаёте желаемую пропускную способность. Платформа управляет лимитами на провайдера. Полную реализацию маршрутизации и отказоустойчивость с учётом задержки смотрите в нашем руководстве по продакшн-мультимодельной настройке и документации по кастомной маршрутизации.

FAQ

Какая самая распространённая ошибка с лимитами?

Повтор с фиксированным интервалом. Одну секунду на всех клиентах создаёт табун, гарантирующий больше 429. Всегда используйте экспоненциальный бэкофф со случайным джиттером. Один джиттер — добавление random.uniform(0, 1) к времени ожидания — предотвращает большинство каскадных сбоев.

Как узнать свои лимиты?

Проверьте дашборд провайдера на заявленные лимиты вашего тарифа. Затем читайте заголовки x-ratelimit-remaining-* на каждом ответе API — они точно показывают оставшийся бюджет в реальном времени. Мониторьте их. Вы не узнаете свои лимиты, пока не упрётесь в них — так работают большинство команд.

Помогает ли использование нескольких провайдеров решить проблему лимитов?

Да — эффективно. Лимит 500 RPM на провайдера становится 2,000 RPM на четырёх провайдерах с раунд-робин маршрутизатором. Агрегационные платформы с маршрутизацией по нескольким провайдерам делают это прозрачным: один endpoint, автоматическое управление лимитами на уровне провайдера. Сочетайте многопровайдерную маршрутизацию со стратегиями оптимизации затрат, чтобы повысить пропускную способность без удвоения счёта — маршрутизируйте более дешёвые модели для некритичных запросов и резервируйте дорогие для задач, которым они нужны.

Какое самое простое исправление можно внедрить сегодня?

Замените повтор с фиксированным интервалом на экспоненциальный бэкофф + джиттер. Пять строк кода. Предотвращает табун, превращающий один 429 в каскадный сбой. Код слоя 2 в этой статье готов к копированию.

Могут ли агрегационные платформы обрабатывать лимиты за меня?

Да. Многопровайдерная маршрутизация, автоматический кулдаун для троттленных провайдеров и унифицированные дашборды лимитов — стандартные функции. Вы настраиваете желаемую пропускную способность. Платформа обрабатывает управление квотами на провайдера, мониторинг заголовков и автоматическую отказоустойчивость. Один endpoint. Никаких 429.

Три слоя здесь — троттлинг, бэкофф, предикция — превращают лимиты из угрозы надёжности в решённую инженерную проблему. Но по мере того как многопровайдерная маршрутизация становится стандартом, а провайдеры конкурируют гарантиями пропускной способности, вопрос меняется: присоединится ли «лимит превышен» к «диск полон» и «память исчерпана» как к ошибкам, которые современная инфраструктура просто делает устаревшими? Пока что код в этой статье держит вас в работе. Длинная дуга указывает на нечто более интересное.

Слой 1 и слой 2 вы можете реализовать за полдня с кодом выше. Слой 3 — предиктивная приостановка — требует больших инвестиций. Агрегационные платформы встраивают все три в путь запроса: многопровайдерная маршрутизация поглощает троттлинг на уровне провайдера, мониторинг заголовков питает общий дашборд лимитов, а автоматический кулдаун держит здоровых провайдеров в ротации, когда один деградирует. Никакая архитектура не устраняет 429 полностью, но распределение трафика по провайдерам и чтение заголовков до столкновения со стеной превращают их из еженедельного инцидента в редкий крайний случай.