Throttling в DRF: чек-лист перед продакшеном
Дефолтный throttling в Django REST Framework хранит счётчики в LocMemCache, ломается при нескольких воркерах и не различает клиентов за одним IP. Разбираем, что проверить до релиза.

Вы включили AnonRateThrottle, выставили лимит 100/hour, задеплоили — и обнаружили, что реальный лимит на прод-сервере с четырьмя воркерами Gunicorn оказался около 400 запросов в час на клиента, а после рестарта воркера счётчик обнулился. Это не баг DRF, а следствие того, что по умолчанию throttling складывает счётчики в локальный кеш процесса. В проде это почти всегда не то, что вам нужно.
Разберём чек-лист перед выкатом: где хранить счётчики, чем платит каждый бэкенд, как различать клиентов за общим IP, что показывать клиенту при блокировке и где throttling DRF ломается под нагрузкой. Актуально для Django REST Framework 3.14–3.15 на Django 4.2 / 5.x.
Как DRF считает лимиты на самом деле
Базовый класс SimpleRateThrottle реализует скользящее окно поверх Django cache. На каждый запрос он строит ключ кеша (по IP или по идентификатору пользователя), достаёт список таймстемпов предыдущих запросов, выбрасывает те, что старше окна, и сверяет длину списка с лимитом. Если запросов меньше лимита — добавляет текущий таймстемп и пишет список обратно.
Отсюда сразу два практических следствия. Первое: точность лимита ровно настолько, насколько согласован кеш между процессами. Второе: между чтением списка и записью обратно нет атомарности — при конкурентных запросах два воркера могут прочитать один и тот же список и оба разрешить запрос. Для грубого ограничения это терпимо, для жёсткого биллингового лимита — нет.
Дефолтный throttling DRF защищает от случайного шторма запросов, но не является механизмом тарификации. Если за превышение лимита клиент платит деньги — считайте лимиты в системе с атомарными операциями, а не в Django cache.
Сноска: скользящее окно
Скользящее окно (sliding window) — способ учёта, при котором лимит считается за последние N секунд относительно текущего момента, а не за фиксированный календарный интервал. В отличие от «фиксированного окна», где счётчик обнуляется в начале часа, скользящее окно не даёт всплеска на стыке интервалов. DRF хранит таймстемпы каждого запроса именно ради скользящего окна — поэтому память под ключ растёт вместе с лимитом.
Где хранить счётчики
Первое решение перед продом — какой cache backend обслуживает throttling. По умолчанию DRF использует кеш с алиасом default. Если у вас там LocMemCache, каждый процесс считает лимиты сам по себе.
| Бэкенд | Общий на воркеры | Переживает рестарт | Что учесть |
|---|---|---|---|
| LocMemCache | Нет | Нет | Лимит умножается на число процессов, сбрасывается при рестарте. Годится только для локальной разработки |
| Redis (django-redis) | Да | Да, если Redis персистентный | Стандартный выбор для прода. Сетевая задержка на каждый запрос |
| Memcached | Да | Нет | После рестарта Memcached счётчики теряются — окно «сбрасывается» разом для всех |
| DatabaseCache | Да | Да | Нагрузка на БД растёт линейно с трафиком, плюс запись на каждый запрос. Не рекомендуется |
Практичный вариант — вынести throttling в отдельный алиас кеша, чтобы очистка основного кеша не сбрасывала лимиты и наоборот:
# settings.py — Django 5.x, django-redis 5.x
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/1",
},
"throttling": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/2",
},
}Чтобы throttle-класс использовал именно этот алиас, задайте атрибут cache в своём подклассе:
from django.core.cache import caches
from rest_framework.throttling import AnonRateThrottle
class BurstAnonThrottle(AnonRateThrottle):
scope = "anon_burst"
cache = caches["throttling"] # отдельный Redis, а не defaultПроверка: после деплоя сделайте больше запросов, чем лимит, с одного IP и убедитесь, что 429 приходит на том же порядковом номере запроса независимо от того, на какой воркер он попал. Если лимит «плавает» кратно числу воркеров — throttling всё ещё сидит в LocMemCache.
Идентификация клиента: IP — это не клиент
AnonRateThrottle по умолчанию берёт IP из request.META. За NAT, корпоративным прокси или мобильным оператором сотни пользователей приходят с одного адреса — все они делят один лимит. И наоборот, за load balancer'ом без правильной настройки все запросы могут выглядеть как приходящие с IP балансировщика.
Ключевые моменты:
- DRF читает IP через настройку
NUM_PROXIESи заголовок изX-Forwarded-For. Если приложение стоит за одним прокси (Nginx), выставьтеNUM_PROXIES = 1, чтобы DRF взял реальный клиентский адрес, а не адрес прокси. - Заголовку
X-Forwarded-Forнельзя доверять, если он приходит напрямую от клиента: его легко подделать и обойти лимит. Убедитесь, что фронтящий Nginx перезаписывает заголовок, а не пробрасывает клиентский. - Для аутентифицированных запросов используйте
UserRateThrottle— он считает поpkпользователя, а не по IP. Это точнее и не страдает от общего NAT.
Типовая связка: AnonRateThrottle для неаутентифицированных запросов плюс UserRateThrottle для залогиненных, оба перечислены в DEFAULT_THROTTLE_CLASSES. Для анонимных запросов сработает первый, для авторизованных — второй, потому что AnonRateThrottle возвращает None (не троттлит) при наличии пользователя.
Разные лимиты для разных эндпоинтов
Единый глобальный лимит редко подходит: логин надо ограничивать жёстче, чем выдачу справочника. Для этого есть ScopedRateThrottle — он берёт лимит из атрибута throttle_scope на вью.
# settings.py
REST_FRAMEWORK = {
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.ScopedRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
"login": "5/min",
"catalog": "1000/hour",
},
}
# views.py
from rest_framework.views import APIView
class LoginView(APIView):
throttle_scope = "login" # 5 запросов в минуту
# ...
class CatalogView(APIView):
throttle_scope = "catalog" # 1000 в час
# ...Формат ставки — число/период, где период это s, min, hour или day (DRF смотрит на первую букву). 10/s и 10/second означают одно и то же.
Что отдавать клиенту при блокировке
При превышении DRF возвращает 429 Too Many Requests и заголовок Retry-After с числом секунд до разблокировки. Это поведение из коробки, но проверьте две вещи перед продом:
- Клиент API умеет читать
Retry-Afterи ждать, а не долбить сервер в цикле. Иначе throttling не снижает нагрузку, а превращается в бесконечный поток 429. - Тело ответа не раскрывает лишнего. Дефолтный текст говорит «Request was throttled. Expected available in N seconds» — это нормально, но если вы кастомизируете
exception_handler, не подставляйте туда внутренние детали лимитов.
Где throttling DRF ломается
Граничные случаи, которые всплывают именно в проде:
- Гонка при конкурентных запросах. Между чтением и записью счётчика нет блокировки. При всплеске параллельных запросов реальный лимит может превысить заданный на несколько единиц. Для точного лимита нужны атомарные операции Redis (например, через отдельный код на Lua или готовую библиотеку rate limiting), а не механизм DRF.
- Рост памяти под ключ. DRF хранит таймстемп каждого запроса в окне. Лимит
100000/hourозначает список до ста тысяч чисел под одним ключом. Для больших лимитов скользящее окно DRF неэффективно — рассмотрите фиксированное окно на уровне Nginx или API-gateway. - Недоступность кеша. Если Redis отвалился, поведение зависит от бэкенда: часть настроек django-redis по умолчанию поднимает исключение, что уронит запрос. Решите заранее, что важнее — доступность или строгость лимита, и настройте
IGNORE_EXCEPTIONSв django-redis соответственно. - Throttling не заменяет защиту от перебора паролей. Лимит по IP обходится сменой адреса. Для логина комбинируйте throttling с блокировкой по учётной записи и капчей.
Чек-лист перед выкатом
- Бэкенд кеша для throttling — не LocMemCache, а общий для всех воркеров (обычно Redis).
- Проверено эмпирически: лимит не умножается на число воркеров.
NUM_PROXIESсоответствует числу прокси перед приложением;X-Forwarded-Forперезаписывается фронтящим Nginx.- Анонимные и аутентифицированные запросы разведены на
AnonRateThrottleиUserRateThrottle. - Критичные эндпоинты (логин, регистрация, отправка кода) имеют отдельный жёсткий scope.
- Клиенты API читают
Retry-After. - Определено поведение при недоступности кеша.
- Для биллинговых лимитов throttling DRF не используется как единственный механизм.
Дальше — навесьте мониторинг на долю 429 в общем трафике: резкий рост означает либо атаку, либо слишком жёсткий лимит, который бьёт по реальным пользователям. И вынесите грубую защиту от DDoS на уровень Nginx или CDN: throttling DRF работает уже внутри Python-процесса, то есть после того, как запрос прошёл через WSGI и занял воркер.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: Django REST Framework — Throttling (официальная документация), django-redis — Configure as cache backend
Частые вопросы
У меня DRF 3.13, а не 3.15 — чек-лист подойдёт?
Почему при тесте лимит срабатывает не на том запросе, что я жду?
Можно ли выключить throttling только для одного вью?
Что будет, если Redis упадёт?
Подходит ли throttling DRF для платных API с тарификацией по числу запросов?
Как различать лимит по IP и по пользователю одновременно?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.