Yazan.
Alle Artikel
9 Min. LesezeitDjangoAPIsBackend

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

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.

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, not redundant. AnonRateThrottle returns None (skip) for authenticated requests, UserRateThrottle returns None for anonymous ones. Each request is checked by exactly one of them.

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 makes throttling a no-op

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. Now 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:

class LoginThrottle(ScopedRateThrottle):
    cache = caches["throttle"]
    scope = "login"

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.

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):
    scope = "login"

    def throttle_failure(self):
        raise Throttled(detail="Too many attempts. Try again later.")

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.