DRF Throttling
Throttles cap request rates. They run after authentication and permissions. A blocked request returns 429 Too Many Requests with a Retry-After header.
Throttling is enforcement, not security — it’s about protecting your service from abuse and runaway clients, not preventing data leaks. For real abuse mitigation pair it with WAF/API-gateway-level limits.
Built-in throttle classes
| Class | Identifies by | Use for |
|---|---|---|
AnonRateThrottle |
client IP | unauthenticated traffic |
UserRateThrottle |
request.user.pk (or IP if anon) |
authenticated traffic |
ScopedRateThrottle |
throttle_scope attribute on the view |
per-endpoint custom rates |
Global config
# settings.py
REST_FRAMEWORK = {
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
"anon": "20/min",
"user": "1000/day",
},
}
Rate format: <num>/<period> where period ∈ second, minute, hour, day. 100/hour means 100 requests rolling-window-style — DRF tracks timestamps, not fixed windows.
Per-view throttles
class ExpensiveView(APIView):
throttle_classes = [UserRateThrottle, BurstThrottle]
All apply (logical AND — every throttle must allow).
Scoped throttles for per-endpoint rates
When two endpoints need different rates and you don’t want to write custom classes for each:
# settings.py
REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = {
"uploads": "10/hour",
"search": "60/min",
}
# views.py
class UploadView(APIView):
throttle_classes = [ScopedRateThrottle]
throttle_scope = "uploads"
class SearchView(APIView):
throttle_classes = [ScopedRateThrottle]
throttle_scope = "search"
Custom throttle subclass
For “logged-in users get a different bucket than anon users” without using ScopedRateThrottle:
class BurstThrottle(UserRateThrottle):
scope = "burst" # uses DEFAULT_THROTTLE_RATES["burst"]
class SustainedThrottle(UserRateThrottle):
scope = "sustained"
# settings:
DEFAULT_THROTTLE_RATES = {"burst": "60/min", "sustained": "1000/day"}
# view:
throttle_classes = [BurstThrottle, SustainedThrottle]
Combining a short burst limit with a long sustained limit is the standard pattern for “1/sec burst, 10k/day sustained.”
Identifying the client — get_cache_key
The throttle uses a cache key like throttle_user_42 or throttle_anon_192.0.2.1. Override get_cache_key() to throttle by API key, tenant, or any header:
class APIKeyThrottle(UserRateThrottle):
scope = "apikey"
def get_cache_key(self, request, view):
api_key = request.META.get("HTTP_X_API_KEY")
if not api_key:
return None # don't throttle if no key — let permissions reject it
return self.cache_format % {"scope": self.scope, "ident": api_key}
Returning None skips this throttle for the request.
Where throttle state is stored
Default: Django’s cache framework (default cache alias). For production, use Redis or another shared cache — LocMemCache is per-process and gives every gunicorn worker its own counter.
# settings.py
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": "redis://localhost:6379/1",
},
}
Algorithm: it’s not a fixed window
DRF stores a list of recent request timestamps per identity, trims entries older than the period, and rejects when the list length ≥ the rate. Effects:
- A burst of 100 requests in second 1 followed by silence still leaves you blocked until those 100 timestamps age out.
- It’s a sliding window log, not a token bucket. Memory grows linearly with rate (not great for
10000/hourper user).
For very high throughput, use an external rate limiter (nginx limit_req, Envoy, API gateway) — DRF throttles are fine for app-level coarse limits.
Disabling throttles
class WebhookView(APIView):
throttle_classes = [] # webhooks should never be throttled — let the upstream retry
Or globally per-environment:
if DEBUG:
REST_FRAMEWORK["DEFAULT_THROTTLE_CLASSES"] = []
The 429 response
HTTP/1.1 429 Too Many Requests
Retry-After: 47
Content-Type: application/json
{"detail": "Request was throttled. Expected available in 47 seconds."}
Retry-After is in seconds. Clients should honor it with exponential backoff on top.
Pitfalls
UserRateThrottlefalls back to IP for anon users. IfAnonRateThrottleis also enabled, anon users have both counters — usually fine.- Multiple workers without a shared cache silently triple/quadruple your effective rate. Always Redis/Memcached in prod.
- Reverse proxy IP confusion. If
request.META["REMOTE_ADDR"]is your nginx, all anon users share one bucket. ConfigureUSE_X_FORWARDED_HOSTand readX-Forwarded-For(carefully — strip spoofed values at the proxy). - Throttles don’t run on
OPTIONSpreflight by default. Usually fine; if it matters, overrideallow_request.
Interview angle
- “How would you rate-limit an endpoint to 10 req/min per user?” —
UserRateThrottlesubclass withscope = "myaction", setDEFAULT_THROTTLE_RATES["myaction"] = "10/min". - “User vs Anon vs Scoped — when do you reach for which?” — Anon for unauth public endpoints, User for authenticated default, Scoped when the rate varies per endpoint and you don’t want one class per endpoint.
- “What algorithm does DRF use? Sliding window? Token bucket?” — sliding window log: stores timestamps in cache, trims by period.
- “Why might throttling not work in production?” —
LocMemCacheper worker — counters aren’t shared. Use Redis/Memcached. - “What status code and headers does a throttled request return?” —
429 Too Many RequestswithRetry-After: <seconds>and adetailmessage.