Learning Platform
Глоссарий Troubleshooting
Урок 10.02 · 24 мин
Начальный
Rate limitingRetry429BackoffJitterTenacity

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

Сервер ограничивает количество запросов по трём причинам:

  1. Защита от DDoS / abuse — без лимита один клиент может положить весь сервис
  2. Справедливое распределение ресурсов — без лимита один тяжёлый клиент влияет на всех остальных
  3. Биллинг — лимиты — это не только защита, но и 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 правильно строить клиент.

Алгоритмы rate limiting
Token bucketПополняется со скоростью R токенов/сек, ёмкость B. Каждый запрос тратит 1 токен. Если токены кончились -- 429. Разрешает burst до B запросов
Leaky bucketОчередь фиксированного размера, обрабатывается с фиксированной скоростью. Запросы сверх ёмкости -- отклоняются. Ровный output, не разрешает burst
Fixed window100 запросов в текущую минуту, считаем с 00:00. Дешёвый. Проблема: 100 в 00:59 + 100 в 01:00 = 200 за 1 секунду
Sliding window100 запросов в последние 60 секунд (скользящее окно). Дороже считать (нужно хранить timestamps), но ровнее ограничивает

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.

Reactive vs Proactive
ReactiveШлём запросы свободно, при 429 -- ждём Retry-After и повторяем. Простой, но 'теряем' запросы (получаем 429), что повышает latency и нагрузку
PlusПростая реализация -- обернул вызов в retry-декоратор
MinusКаждый 429 -- это failed request, потраченный round trip. На дашборде сервера видно как ошибки
ProactiveКлиент знает лимит и сам ограничивает скорость. Никаких 429 в нормальной работе -- replay только при non-rate-limit ошибках
PlusНикаких 429 -- все запросы успешны. Дашборд сервера чистый. Меньше нагрузка на partner
MinusНужна библиотека rate limiter, нужно знать лимит, нужно держать состояние. Чуть сложнее

В 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 (случайность в задержке).

Стратегии jitter (по AWS)
No jitterdelay = base * 2^attempt. Все клиенты ждут одинаково -- thundering herd
Equal jitterdelay = base * 2^attempt / 2 + random(0, base * 2^attempt / 2). Половина детерминированная + случайность. Снижает herd, но не идеально
Full jitterdelay = random(0, base * 2^attempt). Полностью случайно от 0 до max задержки. AWS показал в исследовании: лучшая throughput при retry storm

В 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

Retry timeline с full jitter
Client
API Server
GET /data (request #101)429 + Retry-After: 1Sleep random(0, 1) -- full jitter, attempt=1GET /data (retry 1)429 againSleep random(0, 2) -- attempt=2GET /data (retry 2)200 OK + JSON

Это поведение можно собрать руками или через библиотеки.


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/rate limit
Retry на POSTПовтор POST может создать дубликаты, если сервер уже принял первый запрос (был timeout с нашей стороны). Использовать idempotency keys или retry только на безопасные методы
Игнор Retry-AfterСервер просит подождать 60 секунд, мы шлём через 1 секунду -- снова 429. Уважайте header
Бесконечный retryБез stop_after_attempt -- функция может вечно ретраить. Всегда ставить лимит
Retry на 4xx401, 403, 404, 422 -- это 'не пройдёт никогда'. Повтор бесполезен и тратит лимит. Только 429 и 5xx ретраить

Идемпотентность и 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, что сервис мёртв, и не надоедайте ему».


Проверка знанийKnowledge check
Junior пишет ETL-сервис, читающий из API партнёра с лимитом 100 RPS. Какую комбинацию proactive + reactive стратегии рекомендовать и почему именно так?
ОтветAnswer
Proactive: client-side rate limiter (pyrate-limiter Rate(80, Duration.SECOND)) с лимитом ниже серверного на 20% (запас на неточность измерений и burst). В нормальной работе -- никаких 429. Reactive: tenacity-декоратор с retry_if_exception на 429/502/503/504, stop_after_attempt=5, wait=wait_random_exponential(multiplier=1, max=60) -- full jitter защищает от thundering herd. Уважать Retry-After из ответа: если сервер прислал, спать ровно столько. Почему такая комбинация: (1) Proactive primary -- он защищает от случайной перегрузки сервера и держит вашу работу 'чистой' (ни одного 429 на дашборде партнёра -- они ценят); (2) Reactive secondary -- fallback для случаев, когда сервер ужал лимит динамически (например, на пиковом времени) или ваш лимит чуть неточен. Стратегия одного proactive -- недостаточна (не учитывает динамику сервера). Стратегия одного reactive -- нагружает сервер и ваш ETL отваливается на каждой странице; на 429 теряете время. Combined: оптимальный balance.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. Что означает HTTP-статус 429, и какой заголовок обычно сопровождает его?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 3