Rate limiting в Python: как токен-бакет держит нагрузку
Внешний API вернул 429 и заблокировал ключ на час, потому что клиент отправлял запросы пачками. Разбираем, почему наивный счётчик не спасает и как токен-бакет сглаживает всплески.

Ночной джоб выгружал данные из внешнего API с лимитом 600 запросов в минуту. Формально код не превышал лимит: за минуту он делал ровно 600 обращений. Но провайдер вернул 429 Too Many Requests и временно заблокировал ключ, потому что все 600 запросов уходили в первые три секунды окна, а остальные 57 секунд процесс простаивал. Лимит по минуте соблюдён, лимит по секунде — нет. Разбираем, почему счётчик за окно тут не работает, чем платит каждый из подходов и как токен-бакет сглаживает всплески.
Почему наивный счётчик проваливается
Самое очевидное решение — держать счётчик в памяти и обнулять его раз в минуту. Проблема в двух местах.
Первое — граница окна. Если 600 запросов ушли в последнюю секунду одного окна и ещё 600 в первую секунду следующего, за две смежные секунды провайдер увидит 1200 обращений. Это классический всплеск на стыке окон, из-за которого фиксированное окно (fixed window) считается неточным.
Второе — распределение внутри окна. Провайдер часто ограничивает не только минуту, но и мгновенную частоту: burst-лимит вроде «не больше 20 запросов в секунду». Счётчик за минуту про это ничего не знает и разрешает вылить всю квоту разом.
Лимит «600 в минуту» почти никогда не означает «600 подряд, а потом тишина». Он означает «в среднем 10 в секунду». Задача рейт-лимитера — не пропустить квоту, а размазать её по времени.
Три подхода и их цена
Для одного процесса на Python 3.11+ разумны три варианта. Инвалидация распределённого лимита между воркерами — отдельная тема, здесь разбираем поведение внутри одного процесса и коротко обозначаем переход к нескольким.
Фиксированное окно
Счётчик и метка времени начала окна. Дёшево по памяти, но допускает удвоенный всплеск на стыке. Годится, когда провайдер прощает короткие пики и важна только грубая защита.
Скользящее окно
Храним временные метки всех запросов за последнюю минуту и на каждом обращении отбрасываем те, что старше 60 секунд. Точнее фиксированного окна, но платим памятью: при 600 запросах в минуту в структуре лежит до 600 меток. При лимитах в десятки тысяч это уже заметно.
Токен-бакет
Ведро ёмкостью capacity токенов, которое пополняется со скоростью rate токенов в секунду. Каждый запрос забирает один токен; нет токена — ждём, пока накапает. Ёмкость задаёт допустимый всплеск, скорость — среднюю частоту. Это единственный из трёх подходов, который одновременно держит и среднюю, и мгновенную частоту, не храня историю всех запросов.
| Подход | Память | Точность на стыке | Контроль всплеска |
|---|---|---|---|
| Фиксированное окно | 2 числа | Плохая (до ×2) | Нет |
| Скользящее окно | До N меток | Высокая | Косвенный |
| Токен-бакет | 2 числа | Высокая | Через ёмкость |
Токен-бакет на живом коде
Ключевая идея реализации: не запускать фоновый таймер, который «доливает» токены, а пересчитывать их лениво по прошедшему времени. Так лимитер не тратит ресурсы, пока его не дёргают.
import time
import threading
# Python 3.11+, стандартная библиотека, без зависимостей
class TokenBucket:
def __init__(self, rate: float, capacity: float):
# rate — токенов в секунду, capacity — максимальный всплеск
self.rate = rate
self.capacity = capacity
self.tokens = capacity # стартуем с полным ведром
self.updated = time.monotonic()
self.lock = threading.Lock()
def _refill(self) -> None:
now = time.monotonic()
elapsed = now - self.updated
# доливаем ровно столько, сколько накапало за elapsed секунд
self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
self.updated = now
def acquire(self, amount: float = 1.0) -> None:
# блокирующий вызов: возвращает управление, когда токен доступен
while True:
with self.lock:
self._refill()
if self.tokens >= amount:
self.tokens -= amount
return
# сколько ждать до появления нужного числа токенов
deficit = amount - self.tokens
wait = deficit / self.rate
time.sleep(wait)Обратите внимание на два момента. time.monotonic(), а не time.time(): монотонные часы не прыгают при переводе времени или синхронизации NTP, иначе elapsed может стать отрицательным и всё сломается. И sleep вынесен за пределы блокировки — держать лок во время сна значит заблокировать все остальные потоки на это время.
Для лимита «600 в минуту, всплеск до 20» параметры такие: rate = 10 (600 / 60), capacity = 20. Ведро позволит выпустить до 20 запросов подряд, дальше частота выровняется до 10 в секунду.
Асинхронная версия
Под asyncio тот же принцип, но time.sleep заблокирует весь цикл событий. Нужен await asyncio.sleep(wait), а вместо threading.Lock — asyncio.Lock. Лениво считать токены можно тем же способом через time.monotonic(); сам расчёт частоты не меняется.
Где подход ломается
- Несколько процессов. Ведро в памяти защищает только свой процесс. Запустите четыре воркера с лимитером на 600 — суммарно уйдёт до 2400. Для распределённого лимита состояние выносят в Redis; типовой приём — атомарный Lua-скрипт, который пересчитывает токены и списывает их за одну операцию, чтобы не поймать гонку между воркерами.
- Дробное списание и float. Накопление
elapsed * self.rateв float на длинных интервалах даёт погрешность в доли токена. Для рейт-лимитинга это некритично, но не стройте на этих числах финансовую логику. - Долгий sleep под высоким rate. Если
rateбольшой, аamountзапросили разом большеcapacity,acquireзациклится: столько токенов ведро не накопит никогда. Проверяйтеamount <= capacityна входе. - Ответ 429 всё равно пришёл. Рейт-лимитер знает свою модель лимита, а не реальную. Провайдер может урезать квоту динамически. Уважайте заголовок
Retry-Afterв ответе 429 и притормаживайте фактически, а не только по своей формуле.
Что мерить в проде
После внедрения смотрите на три показателя. Первый — доля ответов 429 к общему числу запросов: она должна упасть до нуля или единичных случаев. Второй — среднее время в acquire: если запросы регулярно ждут больше нескольких секунд, у вас слишком плотный поток для выбранного rate, и очередь растёт. Третий — фактическая частота исходящих запросов, снятая на стороне отправки: сравните её с лимитом провайдера, чтобы убедиться, что параметры rate и capacity подобраны верно.
Дальше: если процессов больше одного, переносите состояние в Redis до того, как поймаете блокировку ключа. И заведите алерт на всплеск Retry-After в ответах — это первый признак, что провайдер изменил лимит и вашу модель пора пересчитать.
Готовые решения тоже есть: библиотеки вроде limits и aiolimiter реализуют эти алгоритмы с поддержкой Redis. Свой токен-бакет оправдан, когда нужен полный контроль над поведением ожидания и минимум зависимостей.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: Python docs: time.monotonic(), limits — rate limiting for Python (документация)
Частые вопросы
У меня asyncio, а не потоки — приведённый код подойдёт?
Запросы всё равно упираются в 429, хотя лимитер стоит — что не так?
Чем токен-бакет лучше скользящего окна?
Почему time.monotonic, а не привычный time.time?
Как сделать общий лимит на несколько процессов?
Стоит ли писать свой лимитер или взять готовую библиотеку?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.