DRF Throttling

5 interview angles 4 min read source

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/hour per 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

  • UserRateThrottle falls back to IP for anon users. If AnonRateThrottle is 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. Configure USE_X_FORWARDED_HOST and read X-Forwarded-For (carefully — strip spoofed values at the proxy).
  • Throttles don’t run on OPTIONS preflight by default. Usually fine; if it matters, override allow_request.

Interview angle

  • “How would you rate-limit an endpoint to 10 req/min per user?”UserRateThrottle subclass with scope = "myaction", set DEFAULT_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?”LocMemCache per worker — counters aren’t shared. Use Redis/Memcached.
  • “What status code and headers does a throttled request return?”429 Too Many Requests with Retry-After: <seconds> and a detail message.