Ошибка API 429: как настроить безопасный Retry-After и экспоненциальный backoff
Практическое руководство по устранению ошибки API 429: разбор лимитов RPM и TPM, чтение заголовка Retry-After и реализация Full Jitter Exponential Backoff в коде.
Содержание
Ошибка API 429: как настроить безопасный Retry-After и экспоненциальный backoff
Ошибка HTTP 429 Too Many Requests возникает, когда клиент превышает установленные провайдером лимиты частоты запросов или объема токенов. Неконтролируемый бесконечный retry не решает проблему, а лишь усугубляет блокировку, приводя к эффекту «шторма повторов» (retry storm).
Чтобы восстановить стабильную работу приложения, необходимо точно определить тип ограничения (RPM, TPM или исчерпание баланса), корректно распарсить заголовок Retry-After и внедрить алгоритм экспоненциального отката со случайным шумом (Full Jitter Exponential Backoff).
Почему возникает HTTP 429: анатомия лимитов
В современных LLM API (OpenAI, Anthropic и совместимых шлюзах) статус 429 генерируется тремя принципиально разными механизмами:
- RPM (Requests Per Minute) — ограничение на количество HTTP-обращений в минуту. Возникает при параллельном запуске нескольких воркеров или циклов без очередей.
- TPM (Tokens Per Minute) — ограничение на суммарный объем входных и выходных токенов за скользящее минутное окно. Часто срабатывает при отправке длинного контекста или больших системных инструкций.
- Исчерпание квоты / баланса (Quota / Balance Exhausted) — блокировка из-за нулевого депозита, достижения жесткого лимита расходов (spend limit) или исчерпания предоплаченного пакета.
HTTP/1.1 429 Too Many Requests
Date: Sun, 23 Aug 2026 03:00:00 GMT
Content-Type: application/json
Retry-After: 6
x-ratelimit-limit-requests: 500
x-ratelimit-remaining-requests: 0
x-ratelimit-reset-requests: 6s
x-ratelimit-limit-tokens: 30000
x-ratelimit-remaining-tokens: 1200
x-ratelimit-reset-tokens: 150ms
{
"error": {
"message": "Rate limit reached for model in organization on tokens per minute (TPM). Please try again in 6s.",
"type": "tokens",
"param": null,
"code": "rate_limit_exceeded"
}
}
Если ошибка вызвана исчерпанием баланса или жестким лимитом подписки, повторные запросы бессмысленны — они только тратят ресурсы сети. Чтобы сразу изолировать причину сбоя и не гадать по кодам в логах, в BetterToken предусмотрен прозрачный Dashboard: в нем в реальном времени отображаются HTTP-статусы каждого запроса, точное распределение Input/Output/Cache токенов и остаток баланса по модели pay-as-you-go без внезапных 5-часовых блокировок подписки.
Диагностическая матрица ошибки 429
| Симптом | Причина | Что проверить в ответе | Инженерное решение |
|---|---|---|---|
| Сбой при параллельных вызовах | Превышен RPM | x-ratelimit-remaining-requests: 0 | Ограничить параллелизм через семафор, внедрить очередь |
| Сбой на длинных промптах | Превышен TPM | x-ratelimit-remaining-tokens < размер промпта | Разбить запрос, оптимизировать системный контекст, включить prompt caching |
| 429 на 100% запросов | Баланс / Квота | Код insufficient_quota или сообщение о billing | Остановить retry, пополнить баланс или проверить права ключа |
| Лавинообразный рост 429 | Retry Storm | Заголовки Retry-After игнорируются воркерами | Добавить случайный джиттер (Jitter) к задержке |
Как правильно читать заголовок Retry-After
Спецификация RFC 6585 предусматривает передачу времени ожидания в заголовке Retry-After в двух форматах:
- Число секунд (целое число или дробное значение, например
Retry-After: 12); - Дата по стандарту HTTP-date (например,
Retry-After: Sun, 23 Aug 2026 03:05:00 GMT).
import datetime
import email.utils
import time
def parse_retry_after(header_value: str | None, default_delay: float = 1.0) -> float:
if not header_value:
return default_delay
header_value = header_value.strip()
try:
# Попытка парсинга секунд (float/int)
return max(0.0, float(header_value))
except ValueError:
pass
try:
# Попытка парсинга HTTP-date
parsed_date = email.utils.parsedate_to_datetime(header_value)
now = datetime.datetime.now(datetime.timezone.utc)
delay = (parsed_date - now).total_seconds()
return max(0.0, delay)
except Exception:
return default_delay
Реализация Full Jitter Exponential Backoff
Если заголовок Retry-After отсутствует, стандартным решением является экспоненциальный откат (Exponential Backoff) с добавлением случайного шума (Full Jitter). Формула вычисления интервала для попытки $i$:
$$T_{\text{wait}} = \text{random}(0, \min(T_{\text{max}}, T_{\text{base}} \times 2^i))$$
Это предотвращает одновременную отправку повторных запросов всеми потоками после истечения тайм-аута.
import asyncio
import json
import random
import httpx
class RateLimitRetryClient:
def __init__(
self,
base_url: str = "https://www.bettertoken.ai/v1",
api_key: str = "",
max_retries: int = 4,
base_delay: float = 1.0,
max_delay: float = 32.0,
):
self.base_url = base_url
self.api_key = api_key
self.max_retries = max_retries
self.base_delay = base_delay
self.max_delay = max_delay
self.client = httpx.AsyncClient(
base_url=self.base_url,
headers={"Authorization": f"Bearer {self.api_key}"},
timeout=60.0,
)
async def send_chat_completion(self, payload: dict) -> dict:
for attempt in range(self.max_retries + 1):
try:
response = await self.client.post("/chat/completions", json=payload)
if response.status_code == 200:
return response.json()
if response.status_code == 429:
error_data = response.json().get("error", {})
error_code = error_data.get("code")
# Если квота исчерпана полностью, повторы бесполезны
if error_code in ("insufficient_quota", "billing_not_active"):
raise RuntimeError(f"Критическая ошибка биллинга: {error_data.get('message')}")
if attempt == self.max_retries:
raise RuntimeError(f"Превышен лимит попыток (429): {response.text}")
# Определяем время ожидания
retry_after = response.headers.get("Retry-After")
if retry_after:
wait_time = parse_retry_after(retry_after) + random.uniform(0.1, 0.5)
else:
# Full Jitter Exponential Backoff
backoff_cap = min(self.max_delay, self.base_delay * (2 ** attempt))
wait_time = random.uniform(0, backoff_cap)
await asyncio.sleep(wait_time)
continue
# Обработка других HTTP-ошибок
response.raise_for_status()
except httpx.RequestError as exc:
if attempt == self.max_retries:
raise
wait_time = min(self.max_delay, self.base_delay * (2 ** attempt))
await asyncio.sleep(wait_time)
raise RuntimeError("Не удалось выполнить запрос: исчерпан лимит повторов")
Безопасность и идемпотентность при повторных запросах
Повторять идемпотентные запросы на чтение данных (GET) безопасно. Однако при работе с генеративными моделями через POST:
- Исключите дублирование задач: если запрос прервался по таймауту после отправки, не повторяйте его немедленно без проверки факта списания или статуса выполнения.
- Используйте клиентские идентификаторы (Client Request ID): передавайте уникальный заголовок
X-Request-IDдля сквозного аудита в логах. - Не маскируйте 401 и 403 под 429: ошибки авторизации требуют исправления ключа, а не паузы в цикле.
Контрольная проверка и валидация восстановления
Чтобы убедиться, что система вышла из режима ограничения лимитов:
- Отправьте одиночный probe-запрос с минимальным промптом (
max_tokens: 5). - Убедитесь в получении HTTP 200 и проверьте заголовок
x-ratelimit-remaining-requests. - Постепенно восстанавливайте параллельный поток задач, контролируя долю ошибок 429 в метриках.
Чтобы исключить неожиданные 429 ошибки из-за жестких минутных лимитов и прозрачно отслеживать статус каждого запроса, перейдите на BetterToken API, создайте изолированный ключ и контролируйте расход токенов в реальном времени через Dashboard.