Throttling в DRF: типичные ошибки и как их избежать
Штатный троттлинг Django REST Framework часто не работает так, как ждёт разработчик: лимиты сбрасываются при перезапуске, считаются на процесс, а IP берётся из заголовка, который легко подделать.

Вы включили AnonRateThrottle в Django REST Framework, поставили 100/hour — и в проде оказалось, что один клиент делает по три тысячи запросов в час, а честного пользователя за общим NAT-адресом блокирует на первой сотне. Это не баг DRF, а следствие того, как устроен его троттлинг: он опирается на кеш, считает по ключу, который вы можете задать неудачно, и по умолчанию хранит счётчики в памяти процесса. Разбираем, где именно ломается защита и как её собрать так, чтобы она держала нагрузку.
Материал написан для DRF 3.14–3.15 и Django 4.2/5.x. Механика классов SimpleRateThrottle в этих версиях не менялась, но проверяйте актуальную документацию, если у вас другая ветка.
Как DRF считает лимиты на самом деле
Все встроенные троттлы наследуются от SimpleRateThrottle. Он делает три вещи: строит ключ через get_cache_key(), достаёт из кеша список таймстампов предыдущих запросов и отбрасывает те, что вышли за окно. Если оставшихся меньше лимита — запрос проходит, таймстамп добавляется обратно в кеш.
Ключевое слово здесь — кеш. Не база, не отдельное хранилище счётчиков, а тот самый Django cache, который у многих в дефолте настроен на LocMemCache. Из этого растут почти все ошибки ниже.
DRF-троттлинг настолько надёжен, насколько надёжен ваш кеш. С локальной памятью процесса это не защита, а её имитация.
Ошибка 1. Лимиты на LocMemCache в проде
По умолчанию Django использует LocMemCache — кеш в памяти процесса. При запуске под Gunicorn с четырьмя воркерами у вас четыре независимых счётчика. Лимит 100/hour превращается в 400/hour, а при автоскейлинге подов — в непредсказуемое число.
Плюс любой рестарт или деплой обнуляет счётчики. Клиент, упершийся в лимит, после релиза получает чистый лист.
Лечится общим хранилищем. Redis через django-redis:
# settings.py — Django 4.2+, django-redis 5.x
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/1",
}
}
# начиная с Django 4.0 бэкенд RedisCache встроен в ядро;
# для более старых версий ставили django-redis отдельноПроверка: сделайте лимит 3/min, запустите приложение в два воркера и постучите четыре раза подряд. С общим кешем четвёртый запрос должен отдать 429 Too Many Requests независимо от того, какой воркер его принял.
Ошибка 2. Доверие к REMOTE_ADDR за прокси
AnonRateThrottle.get_ident() берёт IP клиента. За Nginx или балансировщиком в REMOTE_ADDR лежит адрес самого прокси — один на всех. Итог: либо весь трафик считается как один клиент и лимит выжигается мгновенно, либо (если вы читаете X-Forwarded-For без разбора) клиент подделывает заголовок и обходит лимит одной строкой.
DRF умеет учитывать forwarded-заголовки через настройку NUM_PROXIES. Она говорит фреймворку, сколько доверенных прокси стоит перед приложением, чтобы взять из X-Forwarded-For правильный элемент:
# settings.py
REST_FRAMEWORK = {
"NUM_PROXIES": 1, # один доверенный прокси перед Django
}Если NUM_PROXIES не задан, DRF пытается угадать, и на цепочке из нескольких прокси угадывает неверно. Задавайте число явно и убедитесь, что ваш Nginx действительно перезаписывает X-Forwarded-For, а не проксирует клиентский. Иначе вы доверяете заголовку, которым управляет клиент.
Ошибка 3. UserRateThrottle для анонимов
UserRateThrottle для авторизованного пользователя строит ключ по request.user.pk, а для анонима откатывается на IP. То есть если вы навесили только его, все неавторизованные запросы делят один лимит по IP — и это не то, чего обычно хотят. Анонимов ограничивает AnonRateThrottle, авторизованных — UserRateThrottle. Их ставят вместе:
REST_FRAMEWORK = {
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
"anon": "20/min",
"user": "120/min",
},
}Ошибка 4. Одна ставка на весь API
Глобальные anon/user вешают единый лимит на всё. Но логин, поиск и отдача статики требуют разных порогов: брутфорс пароля надо давить жёстко, а листинг товаров — щадяще. Для этого есть ScopedRateThrottle: он читает атрибут throttle_scope у view и берёт ставку по имени.
# views.py — DRF 3.14+
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from rest_framework.response import Response
class LoginView(APIView):
throttle_classes = [ScopedRateThrottle]
throttle_scope = "login" # своя ставка для попыток входа
def post(self, request):
# проверка учётных данных
return Response({"detail": "ok"})# settings.py
REST_FRAMEWORK = {
"DEFAULT_THROTTLE_RATES": {
"login": "5/min", # защита от перебора пароля
"search": "60/min",
},
}Термин
Скоуп (scope) — именованная область лимитирования. Одна и та же ставка (например 5/min) применяется ко всем view с одинаковым throttle_scope. Удобно, когда несколько эндпоинтов должны делить общий бюджет запросов.
Ошибка 5. Кастомный ключ, который не сбрасывается
Когда штатных классов не хватает, пишут свой get_cache_key. Частая беда — вернуть None при ошибке вычисления ключа. По контракту DRF, если get_cache_key возвращает None, троттл считается неприменимым и запрос пропускается без лимита. То есть баг в ключе бесшумно отключает защиту.
from rest_framework.throttling import SimpleRateThrottle
class OrgRateThrottle(SimpleRateThrottle):
"""Лимит на организацию, а не на пользователя."""
scope = "org"
def get_cache_key(self, request, view):
if not request.user.is_authenticated:
return None # анонимов этот троттл не трогает — осознанно
org_id = getattr(request.user, "org_id", None)
if org_id is None:
# не молчим: без org_id падаем на IP, а не пропускаем запрос
org_id = self.get_ident(request)
return self.cache_format % {"scope": self.scope, "ident": org_id}Разница принципиальная: осознанный return None для анонима — это "не мой случай". А None из-за отсутствия org_id — дыра. Возвращайте fallback-ключ, а не пропуск.
Сводка типичных ошибок
| Симптом | Причина | Что делать |
|---|---|---|
| Лимит превышается в N раз | LocMemCache и N воркеров | Общий кеш (Redis) |
| Счётчики обнуляются на деплое | Кеш в памяти процесса | Вынести в Redis |
| Все клиенты как один | REMOTE_ADDR = IP прокси | Задать NUM_PROXIES |
| Лимит обходится заголовком | Слепое доверие X-Forwarded-For | Nginx перезаписывает заголовок |
| Анонимы делят один лимит | Только UserRateThrottle | Добавить AnonRateThrottle |
| Троттл молча не срабатывает | get_cache_key вернул None | Fallback-ключ вместо None |
Что настроить дальше
DRF-троттлинг — это защита уровня приложения, и она не спасает от объёмных атак: запрос всё равно доходит до Python и жрёт ресурсы, прежде чем получить 429. Грубую фильтрацию по частоте вынесите на уровень выше — в limit_req Nginx или в WAF, а DRF оставьте для тонких бизнес-лимитов вроде 5/min на логин.
После настройки смотрите в проде на долю ответов 429 в метриках и на распределение по скоупам: всплеск на одном эндпоинте покажет, где лимит либо слишком жёсткий, либо реально атакуют. Отдавайте заголовок Retry-After (DRF ставит его автоматически при Throttled), чтобы честные клиенты знали, когда повторить.
Создатель презентаций: Структура и текст каждого слайда: заголовки-утверждения, одна мысль на слайд, заметки докладчику.
Спросить за 150 ₽Источники: Django REST Framework — Throttling, Django — Cache framework (Redis backend)
Частые вопросы
Почему лимит срабатывает не на том числе запросов, что я задал?
У меня другая версия DRF — код подойдёт?
Не сработало: троттл вообще не блокирует запросы. С чего начать?
Можно ли использовать один Redis и для кеша, и для троттлинга?
Как задать разные лимиты для разных эндпоинтов?
Защищает ли DRF-троттлинг от DDoS?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.