Rate limiting и retry: жить с 429 без потерь данных
В предыдущем уроке мы научились забирать данные постранично. Но даже с правильной пагинацией ваш ETL может упасть на 1000-м запросе с HTTPError: 429 Too Many Requests. Это сервер говорит: «слишком быстро, замедлись». Junior, незнакомый с rate limiting, обычно ставит time.sleep(1) в цикл и считает проблему решённой. Через неделю ETL начинает таймаутиться, потому что sleep(1) слишком пессимистичен.
В этом уроке разберёмся, как rate limiting работает на стороне сервера (token bucket vs leaky bucket vs sliding window), какие заголовки сервер шлёт о лимитах, что такое exponential backoff с jitter, и почему AWS рекомендует «full jitter» вместо «equal jitter». Финал — практический Python-клиент с библиотекой tenacity.
Зачем серверу rate limiting
Сервер ограничивает количество запросов по трём причинам:
- Защита от DDoS / abuse — без лимита один клиент может положить весь сервис
- Справедливое распределение ресурсов — без лимита один тяжёлый клиент влияет на всех остальных
- Биллинг — лимиты — это не только защита, но и tarif: «50 RPS на free tier, 5000 RPS на enterprise»
Когда лимит превышен, сервер возвращает 429 Too Many Requests (RFC 6585) и обычно — заголовок Retry-After с подсказкой «попробуй через N секунд».
HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1715300360
{"error": "Rate limit exceeded. Try again in 60 seconds."}
Retry и rate limits: tenacity, exponential backoff, rate limiters
Алгоритмы rate limiting на сервере
Понимание серверной логики помогает Junior DE правильно строить клиент.
Token bucket: разрешает короткие burst
Самый популярный алгоритм. Представьте ведро с токенами: пополняется автоматически (1 токен в секунду = 60 в минуту), ёмкость ограничена (например, 100 токенов max). Каждый запрос тратит 1 токен. Если токены есть — запрос проходит. Если нет — 429.
Ключевая черта: burst-допустимость. Если вы не делали запросов 100 секунд, у вас накопилось 100 токенов. Можно сделать 100 запросов разом — все пройдут. Дальше будет throttle до скорости пополнения (1 RPS).
API провайдеры часто описывают лимиты в формате «100 RPS sustained, burst до 200» — это token bucket с rate=100 и capacity=200.
Leaky bucket: ровный output
Здесь ведро не с токенами, а с запросами: запросы попадают в очередь, обрабатываются с фиксированной скоростью. Если очередь полна — отклоняем. Burst невозможен: даже если послали 1000 запросов разом, обрабатываются они со скоростью «сток».
Используется реже token bucket, но удобен для защиты бэкенда от пиковых нагрузок.
Fixed window: дёшево, но с pitfall
«100 запросов в минуту, считаем с начала минуты». Просто — счётчик в Redis, инкремент, сброс через минуту. Проблема: на стыке окон возможен burst в 200 запросов за 2 секунды (100 в конце 0-й минуты + 100 в начале 1-й минуты).
Sliding window: точнее, дороже
«100 запросов в последние 60 секунд». Нужно хранить timestamps каждого запроса (или агрегаты — какой-то умный алгоритм типа sliding log/window counter). Ограничивает ровно, без stratch на стыках.
В практике большинства API — token bucket или sliding window.
Заголовки rate limit: что читать
Большинство API возвращают заголовки rate limit на КАЖДОМ ответе (не только на 429). Это позволяет клиенту мониторить состояние и замедляться до срабатывания лимита.
Стандартизированных имён заголовков нет (есть RFC draft, но он не финализирован). Самые распространённые:
X-RateLimit-Limit: 5000 # Общий лимит на текущее окно
X-RateLimit-Remaining: 4987 # Сколько запросов осталось
X-RateLimit-Reset: 1715303600 # Unix-timestamp сброса лимита
GitHub использует именно эти. Но есть варианты:
- Twitter:
X-Rate-Limit-Limit,X-Rate-Limit-Remaining,X-Rate-Limit-Reset - Stripe:
Stripe-Should-Retry,Stripe-RateLimit-* - draft RFC:
RateLimit-Limit,RateLimit-Remaining,RateLimit-Reset(без X-)
При 429 шлётся Retry-After (RFC 6585):
Retry-After: 60 # Через N секунд
Retry-After: Wed, 15 May 2026 10:30:00 GMT # Или дата HTTP
В Python:
import requests
response = requests.get("https://api.github.com/users/octocat")
limit = response.headers.get("X-RateLimit-Limit")
remaining = response.headers.get("X-RateLimit-Remaining")
reset = response.headers.get("X-RateLimit-Reset")
print(f"{remaining}/{limit}, reset at {reset}")
Reactive vs Proactive подход
Есть две стратегии работы с rate limits.
В production-grade интеграциях рекомендуется proactive + reactive fallback: основной механизм — клиентский rate limiter (например, pyrate-limiter) с лимитом чуть строже серверного; на случай неточностей или unexpected throttling — retry на 429.
Exponential backoff: математика повторов
Если получили 429 без Retry-After (или хотим перестраховаться), сколько ждать перед повтором?
Линейный backoff (sleep(1), sleep(2), sleep(3)) плох: при долгом throttling медленно адаптируется.
Exponential backoff удваивает задержку каждый раз: sleep(1), sleep(2), sleep(4), sleep(8), sleep(16). Формула: delay = base * 2^attempt.
С maximum cap: delay = min(base * 2^attempt, max_delay). Без cap при 10 попытках задержка вырастает до 1024 секунд — это уже не backoff, а отказ.
import time
def exponential_backoff(attempt: int, base: float = 1, max_delay: float = 60) -> float:
return min(base * (2 ** attempt), max_delay)
# attempt=0: 1s
# attempt=1: 2s
# attempt=2: 4s
# attempt=3: 8s
# attempt=4: 16s
# attempt=5: 32s
# attempt=6: 60s (capped)
Jitter: спасение от thundering herd
Допустим, 100 клиентов одновременно получают 429 (например, у API short outage). Все 100 ждут одинаковое время и одновременно повторяют запрос. Сервер только восстановился — и сразу получает burst в 100 запросов. Снова перегружен -> опять 429 -> опять 100 одновременных повторов через X секунд.
Это называется thundering herd. Решение — jitter (случайность в задержке).
В 2015 AWS опубликовал исследование, сравнивающее три подхода в условиях retry storm. Full jitter показал лучший throughput и наименьшую latency. С тех пор это de facto стандарт.
import random
import time
def full_jitter_backoff(attempt: int, base: float = 1, max_delay: float = 60) -> float:
capped = min(base * (2 ** attempt), max_delay)
return random.uniform(0, capped)
# attempt=0: random(0, 1)
# attempt=1: random(0, 2)
# attempt=2: random(0, 4)
# attempt=3: random(0, 8)
Sequence diagram: 429 + retry с backoff
Это поведение можно собрать руками или через библиотеки.
tenacity: стандартная Python библиотека для retry
tenacity — современная библиотека retry для Python, активно поддерживается, поддерживает разные стратегии backoff и условия повтора.
pip install tenacity
Базовый пример
from tenacity import retry, stop_after_attempt, wait_random_exponential
from tenacity import retry_if_exception_type
import requests
from requests.exceptions import HTTPError
@retry(
retry=retry_if_exception_type(HTTPError),
stop=stop_after_attempt(5),
wait=wait_random_exponential(multiplier=1, max=60),
reraise=True,
)
def fetch(url: str) -> dict:
response = requests.get(url, timeout=10)
response.raise_for_status()
return response.json()
# При HTTPError -- повтор до 5 раз с full jitter exp backoff
data = fetch("https://api.example.com/data")
wait_random_exponential — это и есть full jitter exponential backoff: ждём random(0, multiplier * 2^attempt).
Только на 429 и 5xx, не на 4xx
Не все ошибки имеет смысл повторять. 401 (unauthorized), 404 (not found), 400 (bad request) — это «всё равно не пройдёт». Повторять 429, 502, 503, 504.
from tenacity import retry, stop_after_attempt, wait_random_exponential
from tenacity import retry_if_exception
import requests
def is_retryable(exception) -> bool:
if isinstance(exception, requests.exceptions.RequestException):
if exception.response is None:
return True # network error -- повторяем
status = exception.response.status_code
return status in (429, 502, 503, 504)
return False
@retry(
retry=retry_if_exception(is_retryable),
stop=stop_after_attempt(5),
wait=wait_random_exponential(multiplier=1, max=60),
reraise=True,
)
def fetch_smart(url: str) -> dict:
response = requests.get(url, timeout=10)
response.raise_for_status()
return response.json()
Уважать Retry-After из ответа
Если сервер прислал Retry-After: 30, лучше подождать ровно 30 секунд, а не пытаться раньше:
from tenacity import retry, RetryCallState
from tenacity import wait_base
import requests
class RespectRetryAfter(wait_base):
"""Используем Retry-After из ответа, если есть; иначе exp backoff."""
def __init__(self, fallback_max: float = 60):
self.fallback = wait_random_exponential(multiplier=1, max=fallback_max)
def __call__(self, retry_state: RetryCallState) -> float:
exc = retry_state.outcome.exception()
if isinstance(exc, requests.exceptions.HTTPError):
response = exc.response
if response is not None and "Retry-After" in response.headers:
return float(response.headers["Retry-After"])
return self.fallback(retry_state)
@retry(
retry=retry_if_exception(is_retryable),
stop=stop_after_attempt(10),
wait=RespectRetryAfter(fallback_max=60),
reraise=True,
)
def fetch_smart(url: str) -> dict:
response = requests.get(url, timeout=10)
response.raise_for_status()
return response.json()
Это то, что вы хотите в production: уважение Retry-After + fallback на full jitter, если заголовка нет.
Proactive: aiolimiter / pyrate-limiter
Если хотите proactive подход (никогда не получать 429 в нормальной работе), используйте client-side rate limiter.
pyrate-limiter (sync)
from pyrate_limiter import Duration, Rate, Limiter
# Лимит API партнёра: 100 RPS. Ставим себе 80 (запас).
rate = Rate(80, Duration.SECOND)
limiter = Limiter(rate)
@limiter.as_decorator()
def fetch(item_id: int):
response = requests.get(f"https://api.example.com/items/{item_id}", timeout=10)
response.raise_for_status()
return response.json()
# Если попытаемся вызвать чаще 80 RPS -- функция заблокируется до next slot
for i in range(1000):
data = fetch(i)
aiolimiter (async)
import asyncio
import httpx
from aiolimiter import AsyncLimiter
limiter = AsyncLimiter(80, 1) # 80 acquisitions per 1 second
async def fetch(client: httpx.AsyncClient, item_id: int):
async with limiter:
response = await client.get(f"https://api.example.com/items/{item_id}")
response.raise_for_status()
return response.json()
async def main():
async with httpx.AsyncClient() as client:
tasks = [fetch(client, i) for i in range(1000)]
results = await asyncio.gather(*tasks)
async with limiter: блокирует, пока не появится свободный slot. 1000 задач выполнятся со скоростью 80 RPS, занимая ~12.5 секунд.
Полный production-pattern
Соберём ETL-клиент с proactive rate limit + reactive retry на edge cases.
import requests
from requests.exceptions import RequestException, HTTPError
from pyrate_limiter import Duration, Rate, Limiter
from tenacity import (
retry,
stop_after_attempt,
wait_random_exponential,
retry_if_exception,
)
class APIClient:
def __init__(self, base_url: str, token: str, max_rps: int = 80):
self.base_url = base_url
self.session = requests.Session()
self.session.headers["Authorization"] = f"Bearer {token}"
# Proactive rate limit: чуть строже сервера (сервер 100, мы 80)
self.limiter = Limiter(Rate(max_rps, Duration.SECOND))
@staticmethod
def _is_retryable(exception) -> bool:
if isinstance(exception, requests.exceptions.RequestException):
response = getattr(exception, "response", None)
if response is None:
return True
return response.status_code in (429, 502, 503, 504)
return False
@retry(
retry=retry_if_exception(_is_retryable),
stop=stop_after_attempt(5),
wait=wait_random_exponential(multiplier=1, max=60),
reraise=True,
)
def _request(self, method: str, path: str, **kwargs):
# Proactive -- ждём slot перед запросом
self.limiter.try_acquire("default")
response = self.session.request(
method,
f"{self.base_url}{path}",
timeout=30,
**kwargs,
)
response.raise_for_status()
return response
def get(self, path: str, **kwargs) -> dict:
return self._request("GET", path, **kwargs).json()
Что здесь правильно:
- Proactive rate limiter: 80 RPS, чуть строже сервера (100). В нормальной работе никаких 429.
- Reactive retry на 429/5xx: страховка для всплесков и выбросов.
- Full jitter exponential backoff: защита от thundering herd при retry.
- Только retryable errors (не повторяем 401/404 — бесполезно).
- Timeout на все запросы.
- Session с reusing connection — экономит TCP handshake.
Подводные камни
Идемпотентность и retry для POST
Главное правило: повторять можно только идемпотентные операции. GET, PUT, DELETE — идемпотентны. POST — нет (по умолчанию).
Если нужно retry для POST (например, создание order), используйте idempotency keys — клиент генерирует уникальный ID и шлёт в заголовке. Сервер запоминает: «этот idempotency_key уже обработан, вот результат», возвращает тот же результат на повторе.
import uuid
import requests
def create_order(amount: int, currency: str):
idempotency_key = str(uuid.uuid4())
response = requests.post(
"https://api.example.com/orders",
json={"amount": amount, "currency": currency},
headers={"Idempotency-Key": idempotency_key},
timeout=10,
)
response.raise_for_status()
return response.json()
Stripe — самый известный пример: каждый POST принимает Idempotency-Key, который Stripe запоминает 24 часа. Дубликат запроса с тем же ключом -> возвращает оригинальный результат, не создаёт второй order.
Итоги урока
Rate limiting на сервере: token bucket (популярный, разрешает burst), leaky bucket (ровный output), fixed/sliding window. Сервер шлёт X-RateLimit-Limit/Remaining/Reset на каждом ответе и Retry-After на 429.
Reactive подход: ждать 429 и retry. Простой, но «теряем» запросы. Proactive: client-side rate limiter (pyrate-limiter, aiolimiter) — никаких 429 в нормальной работе. Лучшая практика — proactive + reactive fallback.
Exponential backoff: задержка = base * 2^attempt, capped максимумом. Jitter: добавление случайности против thundering herd. AWS рекомендует full jitter: random(0, capped_delay).
В Python: tenacity для retry-декораторов, pyrate-limiter или aiolimiter для rate limiting. Уважать Retry-After из ответа сервера.
Идемпотентность критична: retry на POST создаёт дубликаты, если сервер не поддерживает Idempotency-Key.
В следующем уроке — circuit breaker pattern. Retry хорош для коротких throttle, но если API партнёра лежит уже час — продолжать ретраить бесполезно. Circuit breaker — это «remember, что сервис мёртв, и не надоедайте ему».