Managing API Rate Limiting with Django REST Framework's Throttling Classes

Proper API rate limiting isn't just a technical consideration. It's a fundamental aspect of responsible system design that protects both your infrastructure and your users.
Django REST Framework ships with it built in, and turning it on takes about thirty seconds. The problem is that the thirty-second version silently does nothing useful in production, for reasons that are not obvious from the docs. So: what the built-ins actually do, the three ways they fail, and what to replace them with.
Every claim below about DRF's own behaviour is read from rest_framework/throttling.py on master, and the relevant lines are quoted where they matter. Where the answer is surprising (and two of them are), open that file rather than trusting this post.
The three built-in classes
AnonRateThrottle
Limits unauthenticated requests, keyed by client IP.
REST_FRAMEWORK = {
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
"anon": "100/day",
},
}
This is your front door. Anything reachable without a token (login, password reset, public search, a contact form) is reachable by everyone, including whoever is scanning your netblock right now.
UserRateThrottle
Same idea, keyed by authenticated user ID instead of IP. This is the one that matters, because IP is a terrible identity: a whole office behind one NAT gateway shares an IP, and an attacker with a proxy pool has thousands. Once a request is authenticated, throttle the account.
REST_FRAMEWORK = {
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
"anon": "100/day",
"user": "5000/day",
},
}
Both classes in the list is correct, but not for the symmetric reason it looks like, and this is worth getting right, because it changes what your anon rate actually means.
AnonRateThrottle.get_cache_key returns None (skip) as soon as request.user.is_authenticated, so it genuinely steps aside for logged-in traffic. UserRateThrottle does not do the mirror image. Read it:
# rest_framework/throttling.py
class UserRateThrottle(SimpleRateThrottle):
scope = "user"
def get_cache_key(self, request, view):
if request.user and request.user.is_authenticated:
ident = request.user.pk
else:
ident = self.get_ident(request) # falls back to the IP
return self.cache_format % {"scope": self.scope, "ident": ident}
There is no return None on the anonymous branch. So with both classes configured:
- an authenticated request is checked by
UserRateThrottleonly, keyed on the user id; - an anonymous request is checked by both: against
anonat 100/day and againstuserat 5000/day, sharing that second bucket by IP with every other anonymous caller from the same address.
In this configuration the lower limit binds, so the behaviour is what you wanted. But user is not a logged-in-only budget, and if you ever set user below anon you will be rate-limiting anonymous traffic on the user bucket without meaning to. Check rest_framework/throttling.py rather than assuming the two classes are opposites.
ScopedRateThrottle
Per-endpoint limits. The most useful of the three, because a global rate is always wrong in one direction or the other.
REST_FRAMEWORK = {
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.ScopedRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
"reports": "10/hour",
"login": "5/min",
"reads": "1000/hour",
},
}
class ReportView(APIView):
throttle_classes = [ScopedRateThrottle]
throttle_scope = "reports"
A report that runs a twelve-second aggregate query and a read that hits one indexed row do not deserve the same budget. Price the endpoint by what it costs you.
Failure 1: the default cache multiplies your limit by your worker count
This is the big one.
DRF throttles are counters in Django's default cache. If you never configured CACHES, Django uses LocMemCache, a per-process dictionary. The throttle still runs and still returns 429s; the problem is that there is one counter per process rather than one per deployment. Count your processes: Gunicorn with four workers gives you four independent counters, each seeing roughly a quarter of the traffic. A 5/min login limit becomes an effective 20/min, and it changes every time you scale.
Worse, LocMemCache is wiped on every deploy and every worker restart. An attacker being throttled gets a clean slate whenever you ship.
Use a shared cache:
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/1",
},
}
If you want throttle state isolated from your general-purpose cache (so that flushing one doesn't reset the other), give throttles their own alias and point the classes at it:
from django.core.cache import caches
from rest_framework.throttling import ScopedRateThrottle
class LoginThrottle(ScopedRateThrottle):
cache = caches["throttle"]
class LoginView(APIView):
throttle_classes = [LoginThrottle]
throttle_scope = "login" # this is where the scope has to live
Do not set scope = "login" on the subclass. It looks like it should work and it silently does nothing, because ScopedRateThrottle overwrites it on every call:
# rest_framework/throttling.py
def allow_request(self, request, view):
self.scope = getattr(view, self.scope_attr, None) # scope_attr == "throttle_scope"
if not self.scope:
return True # no scope on the view -> allowed
...
Whatever you assigned on the class is replaced by view.throttle_scope. If the view doesn't define one, self.scope is None and allow_request returns True for every request. The endpoint you were most careful about is the one with no limit at all, and nothing in the logs says so. The only way to catch this is the test below.
Then verify it. A rate limit nobody has tested is a rate limit that doesn't exist:
from django.core.cache import cache
from rest_framework.test import APITestCase
class LoginThrottleTests(APITestCase):
def setUp(self):
cache.clear() # throttle history leaks between tests otherwise
def test_sixth_attempt_in_a_minute_is_throttled(self):
payload = {"username": "a", "password": "wrong"}
for _ in range(5):
self.client.post("/api/login/", payload)
response = self.client.post("/api/login/", payload)
self.assertEqual(response.status_code, 429)
self.assertIn("Retry-After", response.headers)
That cache.clear() in setUp is not optional. Without it the throttle history from one test bleeds into the next and the suite fails in whatever order the runner happens to pick.
This test is also the one that catches the scope mistake above: with throttle_scope missing from the view, allow_request returns True every time, the sixth attempt comes back 200, and the assertion fails. That is the whole reason to write it.
And if you want to see the anon/user overlap for yourself rather than taking the paragraph above on trust, assert the boundary from both sides:
from django.contrib.auth import get_user_model
from django.core.cache import cache
from rest_framework.test import APITestCase
class ThrottleIdentityTests(APITestCase):
"""With anon=2/min and user=5/min in DEFAULT_THROTTLE_RATES."""
def setUp(self):
cache.clear()
def test_anonymous_requests_stop_at_the_anon_rate(self):
for _ in range(2):
self.assertEqual(self.client.get("/api/items/").status_code, 200)
# UserRateThrottle also ran, keyed on the IP, but anon is lower,
# so anon is the limit that bites.
self.assertEqual(self.client.get("/api/items/").status_code, 429)
def test_authenticated_requests_get_the_user_rate(self):
user = get_user_model().objects.create_user("a", password="x")
self.client.force_authenticate(user)
for _ in range(5):
self.assertEqual(self.client.get("/api/items/").status_code, 200)
# AnonRateThrottle skipped this request entirely; five, not two.
self.assertEqual(self.client.get("/api/items/").status_code, 429)
Failure 2: the counter is not atomic
DRF's SimpleRateThrottle reads the request history, appends to it, and writes it back:
# rest_framework/throttling.py, in essence
self.history = self.cache.get(self.key, [])
# ... drop entries older than the window ...
self.history.insert(0, self.now)
self.cache.set(self.key, self.history, self.duration)
Read, modify, write, with no lock. Two concurrent requests both read a history of length 4 against a limit of 5, both decide they are allowed, and both write. The counter ends at 5 while 6 requests went through.
For a 1000/hour read budget, nobody cares; the overshoot is noise. For a 5/min login limit under a credential-stuffing burst, it means an attacker running 50 parallel connections gets meaningfully more attempts than the number in your settings file. The limit degrades exactly when it's under attack, which is the only time it matters.
If you need the number to be the actual number, do the counting where it can be atomic:
import time
from django.core.cache import caches
from rest_framework.throttling import BaseThrottle
class AtomicFixedWindowThrottle(BaseThrottle):
"""
Fixed-window counter using Redis INCR, which is atomic, unlike
SimpleRateThrottle's read-modify-write, this cannot overshoot under
concurrency. Assumes django-redis, whose client exposes the raw
connection; the stdlib RedisCache backend reaches it differently.
"""
rate = 5
window = 60
scope_name = "login"
def allow_request(self, request, view):
ident = self.get_ident(request)
bucket = int(time.time() // self.window)
key = f"throttle:{self.scope_name}:{ident}:{bucket}"
client = caches["throttle"].client.get_client(write=True)
count = client.incr(key)
if count == 1:
# First hit in this window; expire the key with the window so old
# buckets clean themselves up.
client.expire(key, self.window)
return count <= self.rate
Fixed windows have a known edge: a client can spend its full budget at the end of one window and again at the start of the next, briefly doubling the rate. If that matters, use a sorted-set sliding window or a token bucket, but be honest about whether it matters for the endpoint in question. For login, fixed windows are fine. For a metered billing API, they are not.
Failure 3: throttling by IP behind a proxy
Behind a load balancer, every request arrives from the balancer's IP. REMOTE_ADDR is the proxy, so AnonRateThrottle puts your entire anonymous traffic into one bucket: the first hundred visitors of the day exhaust the limit for everyone.
DRF handles this if you tell it how many proxies to trust:
NUM_PROXIES = 1 # count of proxies between the client and Django
NUM_PROXIES makes DRF read X-Forwarded-For and take the n-th address from the right. Setting it too high hands the throttle key to the client, because the left-hand entries of X-Forwarded-For are attacker-supplied and rotating them defeats the limit entirely. Count your real hops and set exactly that number.
Responding to a throttle properly
DRF returns 429 Too Many Requests with a Retry-After header. Two things follow.
Actually surface Retry-After to clients. It is the difference between a client that backs off and a client that hammers you at full speed until the window rolls over. If you have a first-party SDK, make it honour the header.
Don't leak the limit on unauthenticated endpoints. The default message includes the wait time, which tells an attacker exactly how to pace their requests. On login and password reset, override it:
from rest_framework.exceptions import Throttled
from rest_framework.throttling import ScopedRateThrottle
class LoginThrottle(ScopedRateThrottle):
def throttle_failure(self):
raise Throttled(detail="Too many attempts. Try again later.")
class LoginView(APIView):
throttle_classes = [LoginThrottle]
throttle_scope = "login" # again: on the view, not the throttle
Rate limiting isn't a feature you add once and forget. It's a set of numbers that encode what your infrastructure can afford, and they only mean anything if the counter is shared, atomic where it counts, keyed to something the client can't spoof, and covered by a test that fails when someone raises the limit by accident.