Filter concepts by levelShowing all levels.

Django · Section 51

DRF Throttling and Rate Limiting

Level
advanced
Read
28 min
Concepts
3

A throttle counts requests against a key and returns 429 with `Retry-After` once the rate is exceeded. The three shipped classes differ only in the key: `AnonRateThrottle` uses the client IP and applies to unauthenticated traffic only, `UserRateThrottle` uses the user id (falling back to the IP), and `ScopedRateThrottle` uses a `throttle_scope` declared on the view, which is how login and password reset get 5/min while the general API keeps a generous default. Listing one is not enough — the endpoints most worth abusing are the ones where no user exists yet. Setting `throttle_classes` on a view replaces the global list rather than extending it, and behind a proxy `NUM_PROXIES` must match the real hop count or the IP key is either the load balancer's address or a caller-forgeable header. The setting that decides whether any of this is real is the cache: DRF stores counters in Django's cache, and the default `LocMemCache` is per process, so four workers across three hosts enforce twelve independent copies of the limit and a deploy resets all of them. A shared Redis or Memcached backend collapses them into one. A custom throttle is `SimpleRateThrottle` plus `get_cache_key()`, which is how you key on a tenant or an API key instead of a caller. Beyond rate limiting, abuse prevention is layered — the edge for volume, lockout for credential stuffing, uniform responses for enumeration, query cost limits for single-request abuse — and quotas are a separate mechanism entirely: exact, durable accounting in a transactional row rather than an approximate cache counter.

What is true here

  1. The three shipped classes differ only in how the throttle key is derived: IP, user id, or scope plus key.
  2. throttle_classes on a view replaces the global defaults instead of adding to them.
  3. LocMemCache gives one counter per process, so the enforced rate is multiplied by workers × instances.
  4. A custom throttle is SimpleRateThrottle with get_cache_key(); returning None opts a request out.
  5. Throttles shape traffic and may be approximate; quotas are accounting and belong in a transactional database row.

What you will be able to do

  • Configure anonymous, per-user, and per-endpoint limits that cover the whole traffic mix
  • Explain why a rate limit measured on one dev process does not hold in production
  • Write a custom throttle keyed on something other than the caller
  • Choose the right control for an abuse pattern that rate limiting cannot see
  • Implement a billable quota exactly, and report it through the same 429 contract
One request through the throttle layer, and where each decision is made
notapplicablea keyexhausted —raise Throttledwithin limit

Request (authenticated or not)

throttles run after authentication and permissions

get_cache_key()

IP · user.pk · scope+ident · tenant — the design decision

Returns None

this class does not apply to this request

Read the counter from Django's cache

LocMemCache = per process; Redis = per cluster

Under the rate

record the timestamp and continue

Over the rate

wait() computes how long until the window frees up

429 Too Many Requests

with Retry-After, from wait()

Quota check (separate mechanism)

a transactional row — exact, durable, billable

The view runs

  • Request (authenticated or not) — throttles run after authentication and permissions
    • leads to get_cache_key()
  • get_cache_key() — IP · user.pk · scope+ident · tenant — the design decision
    • leads to Returns None (not applicable)
    • leads to Read the counter from Django's cache (a key)
  • Returns None — this class does not apply to this request
    • leads to Quota check (separate mechanism)
  • Read the counter from Django's cache — LocMemCache = per process; Redis = per cluster
    • leads to Under the rate
    • leads to Over the rate
  • Under the rate — record the timestamp and continue
    • leads to Quota check (separate mechanism)
  • Over the rate — wait() computes how long until the window frees up
    • on error, leads to 429 Too Many Requests
  • 429 Too Many Requests — with Retry-After, from wait()
  • Quota check (separate mechanism) — a transactional row — exact, durable, billable
    • on error, leads to 429 Too Many Requests (exhausted — raise Throttled)
    • leads to The view runs (within limit)
  • The view runs

The three shipped classes

Anonymous, per-user, and scoped throttling — and why the choice of key is the whole design.

Anonymous, user, and scoped throttling

coreintermediate

A throttle counts requests against a key and refuses once the count exceeds a rate. DRF ships three classes that differ only in how the key is derived. `AnonRateThrottle` throttles unauthenticated callers by IP address. `UserRateThrottle` throttles by user id when signed in, falling back to the IP when not. `ScopedRateThrottle` reads a `throttle_scope` attribute off the view and applies the rate configured for that scope, which is how one endpoint gets a tighter limit than the rest of the API. Rates are strings like `"100/hour"` or `"5/min"`, and when a caller exceeds one, DRF returns 429 with a `Retry-After` header saying how long to wait.

Think of it as

A throttle is a counter keyed by "who", and the choice of key is the whole design. Keying on IP is the only option for anonymous traffic, and it is a blunt one: a corporate NAT, a university, or a mobile carrier puts thousands of unrelated people behind one address, so an IP limit generous enough not to break them is generous enough to be useless against a determined single attacker. Keying on user id is precise but only exists after authentication, which is exactly when you least need protection — the expensive-to-abuse endpoints are usually login and password-reset, where there is no user yet. So the two classes are not alternatives; they cover different halves of the traffic and belong in the list together. Scoped throttling then handles the fact that "requests per hour" is a bad unit for a whole API: reading a cached list and sending a verification email do not deserve the same budget. The scope makes the limit a property of the endpoint rather than of the project, which is what lets the global rate stay generous while the few genuinely expensive operations stay tight.

python
class PasswordResetView(APIView):
    throttle_classes = [ScopedRateThrottle]
    throttle_scope = "password_reset"     # rate from DEFAULT_THROTTLE_RATES["password_reset"]

What we're doing: A generous global budget, with the two endpoints worth abusing held to a much tighter one.

config/settings.py + accounts/views.pypython
REST_FRAMEWORK = {
    "DEFAULT_THROTTLE_CLASSES": [
        "rest_framework.throttling.AnonRateThrottle",
        "rest_framework.throttling.UserRateThrottle",
    ],
    "DEFAULT_THROTTLE_RATES": {
        "anon": "60/hour",
        "user": "2000/hour",
        "login": "5/min",
        "password_reset": "3/hour",
    },
}


class LoginView(APIView):
    permission_classes = [AllowAny]
    throttle_classes = [ScopedRateThrottle]
    throttle_scope = "login"


class PasswordResetView(APIView):
    permission_classes = [AllowAny]
    throttle_classes = [ScopedRateThrottle]
    throttle_scope = "password_reset"
3–4
Both classes, not one. `AnonRateThrottle` covers pre-login traffic by IP; `UserRateThrottle` gives each account its own budget once identity exists.
9–10
Scope rates live in the same dict as the defaults. The scope name is arbitrary — it just has to match the view's `throttle_scope`.
17
Setting `throttle_classes` on the view *replaces* the default list rather than adding to it, so this endpoint is now governed by the scope alone — deliberate here, since 5/min is stricter than either default.
24
Three password resets an hour per IP. Low enough to stop enumeration, high enough that a real person who mistypes their email twice is not locked out.

Why this works: A single global rate has to be loose enough for the busiest legitimate client, which makes it useless on the endpoints that send email or verify credentials. Scoping moves the limit to where the cost is, so the general API stays fast and the expensive operations stay protected.

Setting `throttle_classes` on a view and losing the defaults you meant to keep

Wrong

python
class ExportView(APIView):
    throttle_classes = [ScopedRateThrottle]
    throttle_scope = "export"
# The anon and user throttles no longer apply here — the list was replaced, not extended.

Better

python
class ExportView(APIView):
    throttle_classes = [AnonRateThrottle, UserRateThrottle, ScopedRateThrottle]
    throttle_scope = "export"

What you see: Nothing looks wrong — the scope limit works, and the endpoint returns 429 at the expected rate. The loss is invisible: the account-wide budget that was supposed to cap total usage across all endpoints no longer counts this one.

Why: DRF resolves `throttle_classes` as an override, not an addition, exactly like `permission_classes` and `authentication_classes`. Every class in the list runs and any one of them can reject, so listing the defaults alongside the scoped class is what keeps both limits in force. This is easy to miss because the symptom is an absence of enforcement rather than an error.

Three classes, three keys — and why you need more than one

AnonRateThrottle

Unauthenticated only

signed-in requests skip it entirely

One key per address

a NAT puts thousands of people in one bucket

The only option before login

which is where the abuse actually is

UserRateThrottle

Precise for signed-in callers

one account cannot hide behind a shared address

The general per-account budget

generous — it is a backstop, not the real defence

Falls back to IP

so it also covers anonymous traffic, coarsely

ScopedRateThrottle

throttle_scope = "login"

the limit becomes a property of the endpoint

Tight where cost is high

password reset, email send, export, search

No scope, no throttle

a view without the attribute is untouched by this class

  • A request arrives
  • AnonRateThrottle — key = client IP
    • Unauthenticated only — signed-in requests skip it entirely
    • One key per address — a NAT puts thousands of people in one bucket
    • The only option before login — which is where the abuse actually is
  • UserRateThrottle — key = user.pk, or IP when anonymous
    • Precise for signed-in callers — one account cannot hide behind a shared address
    • The general per-account budget — generous — it is a backstop, not the real defence
    • Falls back to IP — so it also covers anonymous traffic, coarsely
  • ScopedRateThrottle — key = scope + user.pk or IP
    • throttle_scope = "login" — the limit becomes a property of the endpoint
    • Tight where cost is high — password reset, email send, export, search
    • No scope, no throttle — a view without the attribute is untouched by this class

The three shipped throttle classes

The three shipped throttle classes
ClassKeyApplies toRate comes from
`AnonRateThrottle`client IPunauthenticated requests only`DEFAULT_THROTTLE_RATES["anon"]`
`UserRateThrottle``user.pk`, or IP if anonymousevery request`DEFAULT_THROTTLE_RATES["user"]`
`ScopedRateThrottle`scope + `user.pk` or IPviews declaring `throttle_scope``DEFAULT_THROTTLE_RATES[<scope>]`
a custom subclasswhatever `get_cache_key()` returnswherever you list itits own `scope` attribute

Together

python
REST_FRAMEWORK = {
    "DEFAULT_THROTTLE_CLASSES": [
        "rest_framework.throttling.AnonRateThrottle",
        "rest_framework.throttling.UserRateThrottle",
    ],
    "DEFAULT_THROTTLE_RATES": {"anon": "60/hour", "user": "2000/hour", "login": "5/min"},
}

Remember: A throttle is a counter keyed on "who". `AnonRateThrottle` keys on IP and covers only unauthenticated traffic; `UserRateThrottle` keys on the user id (IP when anonymous) — list both, since they cover different halves. `ScopedRateThrottle` moves the limit onto the endpoint via `throttle_scope`, which is how login and password reset get 5/min while the API keeps a generous default. Setting `throttle_classes` on a view replaces the defaults rather than adding to them, and behind a proxy you must set `NUM_PROXIES` or the IP key is meaningless.

See also: throttle backends and multiple instances · abuse prevention and quotas · authentication order anonymous users and failures

Advertisement

The cache backend and custom throttles

Why LocMemCache multiplies your limit, what Redis fixes, and keying on something other than the caller.

Custom throttles, Redis, and the multi-instance problem

coreadvanced

DRF stores throttle counters in Django's cache. That detail decides whether your rate limit means anything: with the default `LocMemCache`, every process keeps its own counters, so four Gunicorn workers across three instances enforce twelve independent copies of a "100/hour" limit — an effective 1200/hour. Pointing the cache at Redis (or Memcached) gives every process one shared counter, and the configured rate becomes the real rate. A custom throttle subclasses `SimpleRateThrottle` and implements `get_cache_key()` — that one method decides what is being counted, which is how you throttle per API key, per tenant, or per target account rather than per caller.

Think of it as

The rate you configure is a rate *per counter*, and the number of counters is decided by your deployment, not by your settings file. `LocMemCache` is per process; a process is one Gunicorn worker; workers multiply by instances; and nothing in DRF warns you about any of it, because from inside one process the throttle is working perfectly. That is why this failure survives code review and load tests on a single dev server, and only shows up as "we are rate-limited at 100/hour but the abusive client is doing 900". Moving the counter to Redis fixes the multiplication but not the second, smaller issue DRF documents: the check-then-increment is not atomic, so under real concurrency a few extra requests slip through. That is acceptable for abuse control and not acceptable for a hard quota you bill against — the distinction being that a throttle is a *shaping* mechanism with a tolerance, while a quota is an *accounting* mechanism that has to be exact, and exact accounting belongs in the database with a transaction, not in a cache.

python
class TenantRateThrottle(SimpleRateThrottle):
    scope = "tenant"
    cache = caches["throttling"]

    def get_cache_key(self, request, view):
        if not request.user.is_authenticated:
            return None            # None means "not throttled by this class"
        return f"throttle_tenant_{request.user.tenant_id}"

What we're doing: Throttle per tenant rather than per user, on a shared Redis cache, so one noisy customer cannot starve the rest.

config/settings.py + common/throttling.pypython
CACHES = {
    "default": {"BACKEND": "django.core.cache.backends.redis.RedisCache",
                "LOCATION": env("REDIS_URL")},
    "throttling": {"BACKEND": "django.core.cache.backends.redis.RedisCache",
                   "LOCATION": env("REDIS_URL"), "KEY_PREFIX": "thr"},
}

REST_FRAMEWORK = {
    "DEFAULT_THROTTLE_RATES": {"tenant": "10000/hour", "tenant_write": "600/min"},
}


class TenantRateThrottle(SimpleRateThrottle):
    scope = "tenant"
    cache = caches["throttling"]

    def get_cache_key(self, request, view):
        if not request.user.is_authenticated:
            return None
        return self.cache_format % {
            "scope": self.scope,
            "ident": request.user.tenant_id,
        }
4–5
A separate alias with its own `KEY_PREFIX`. Throttle counters and cached pages have completely different eviction needs, and mixing them means a cache flush silently resets every rate limit.
15
Binding `cache` on the class is what routes this throttle to the dedicated alias — the default is `caches["default"]`.
17–18
Returning `None` opts a request out of this throttle entirely. Anonymous callers have no tenant, so they belong to `AnonRateThrottle` instead.
20–23
`cache_format` is DRF's own key template (`throttle_%(scope)s_%(ident)s`), so custom keys sit in the same namespace as the shipped classes and stay debuggable.

Why this works: Keying on tenant rather than user turns the limit into a per-customer capacity guarantee: a customer with 500 employees cannot consume the whole service by having each of them stay under a per-user limit.

Enforcing a billed quota with a throttle

Wrong

python
class BilledQuotaThrottle(SimpleRateThrottle):
    scope = "quota"          # "50000/day", counted in the cache
    def get_cache_key(self, request, view):
        return f"throttle_quota_{request.user.account_id}"
# Cache eviction, a Redis restart, or a race all lose count — silently.

Better

python
with transaction.atomic():
    usage = (AccountUsage.objects
             .select_for_update()
             .get(account_id=account_id, period=current_period))
    if usage.calls >= usage.limit:
        raise Throttled(detail="Monthly quota exhausted.")
    usage.calls = F("calls") + 1
    usage.save(update_fields=["calls"])

What you see: Monthly usage reported to the customer disagrees with what the application counted, and the direction of the error is unpredictable — a Redis restart loses counts, while a burst of concurrent requests double-counts or under-counts a few.

Why: A throttle and a quota look alike and have opposite requirements. A throttle shapes traffic and can tolerate being approximate; DRF says so directly, noting its implementations are open to race conditions. A quota is accounting — it has to be exact, durable, and auditable, which means a database row updated inside a transaction, not a cache key with a TTL. Using the cache for it makes the number you bill against dependent on your cache's uptime.

The same "100/hour" setting, on two cache backends

LocMemCache — counters per process

  • +Each Gunicorn worker holds its own dict of counters.
  • +4 workers × 3 instances = 12 independent limits.
  • +A "100/hour" rate admits about 1200 requests an hour.
  • +Every deploy or worker restart resets all counters to zero.
  • +Looks completely correct on a single-process dev server.

Redis — one shared counter

  • One counter per key, seen by every worker on every host.
  • The configured rate is the enforced rate.
  • Counters survive deploys and worker restarts.
  • Still not atomic — DRF documents a few extra requests under high concurrency.
  • A separate cache alias keeps throttle keys out of the page cache.
  • LocMemCache — counters per process
    • Each Gunicorn worker holds its own dict of counters.
    • 4 workers × 3 instances = 12 independent limits.
    • A "100/hour" rate admits about 1200 requests an hour.
    • Every deploy or worker restart resets all counters to zero.
    • Looks completely correct on a single-process dev server.
  • Redis — one shared counter
    • One counter per key, seen by every worker on every host.
    • The configured rate is the enforced rate.
    • Counters survive deploys and worker restarts.
    • Still not atomic — DRF documents a few extra requests under high concurrency.
    • A separate cache alias keeps throttle keys out of the page cache.

What the configured rate actually enforces

What the configured rate actually enforces
Cache backendCounter scope"100/hour" really meansSurvives a deploy?
`LocMemCache` (default)one Python process100 × workers × instancesno — cleared on restart
`FileBasedCache`one host100 × instancesyes, but slow and lock-prone
Memcachedthe whole cluster100yes, until eviction
Redisthe whole cluster100yes, and persistable

Together

python
CACHES = {
    "default": {"BACKEND": "django.core.cache.backends.redis.RedisCache",
                "LOCATION": env("REDIS_URL")},
}

Remember: The rate you configure is a rate per counter, and `LocMemCache` gives you one counter per process — so `workers × instances` multiplies your limit, and a deploy resets it. A shared Redis or Memcached cache makes the configured rate the real rate; a separate cache alias keeps throttle keys out of a page-cache flush. A custom throttle is `SimpleRateThrottle` plus `get_cache_key()`, where returning `None` opts the request out. And never bill against a throttle: DRF documents its counters as approximate, so a real quota belongs in a transactional database row.

See also: anon user and scoped throttling · abuse prevention and quotas · session storage backends

Advertisement

Abuse prevention and quotas

The layers a request counter cannot replace, and why a billable quota needs a transaction.

Abuse prevention and quotas

standardadvanced

Rate limiting is one layer of abuse prevention, not the whole of it. Above it sits the edge — a CDN or WAF that drops obvious floods before they reach Python at all. Below it sit application-level defences that a request counter cannot express: locking an account after repeated failed logins, requiring a proof-of-work or CAPTCHA once a caller looks automated, and making enumeration endpoints answer identically whether or not the thing exists. A quota is a different mechanism again: throttles shape traffic over short windows and may be approximate, while a quota counts billable usage over a billing period and has to be exact — so it belongs in a database row updated inside a transaction, not in a cache counter.

Think of it as

Ask what an attacker gains per request, and put the defence where that gain is. Rate limiting assumes the cost is in *volume*, which is true for scraping and for brute force — but plenty of abuse needs very few requests. Three password resets can enumerate three accounts if the response differs between "sent" and "no such user"; one request to a badly-scoped endpoint can export a whole table. No limit fixes those, because the requests are individually legitimate. So the layers are not redundant copies of one another: the edge handles volume cheaply, the throttle handles per-caller pacing, account lockout handles credential guessing specifically, and uniform responses handle the leaks that volume-based defences cannot see. Quotas then sit outside this stack entirely. The word "limit" makes them sound like throttles, but a throttle answers "may this request proceed right now?" while a quota answers "how much has this account consumed this month, and is that number defensible on an invoice?". The first tolerates being off by a few; the second does not, which is why it needs a transaction and a durable row rather than a cache key with a TTL.

python
with transaction.atomic():
    usage = AccountUsage.objects.select_for_update().get(account=account, period=period)
    if usage.calls >= usage.limit:
        raise Throttled(detail="Monthly quota exhausted.")
    usage.calls = F("calls") + 1
    usage.save(update_fields=["calls"])

What we're doing: Enforce a monthly quota exactly, and report it through the same 429 contract a throttle uses.

billing/models.py + billing/permissions.pypython
class AccountUsage(models.Model):
    account = models.ForeignKey(Account, on_delete=models.CASCADE)
    period = models.DateField()               # first day of the billing month
    calls = models.PositiveIntegerField(default=0)
    limit = models.PositiveIntegerField()

    class Meta:
        constraints = [
            models.UniqueConstraint(fields=["account", "period"], name="usage_per_period"),
            models.CheckConstraint(condition=Q(calls__lte=F("limit")), name="usage_within_limit"),
        ]


def consume_one_call(account):
    with transaction.atomic():
        usage = (AccountUsage.objects
                 .select_for_update()
                 .get(account=account, period=current_period()))
        if usage.calls >= usage.limit:
            raise Throttled(
                detail="Monthly quota exhausted.",
                wait=seconds_until_next_period(),
            )
        usage.calls = F("calls") + 1
        usage.save(update_fields=["calls"])
7–11
The database holds the invariant, not just the application. The `CheckConstraint` means no code path — a migration, a shell session, a bug — can push usage past the limit unnoticed.
15
`transaction.atomic()` plus `select_for_update()` serialises concurrent calls for one account, which is what makes the count exact rather than approximate.
20–23
Raising `Throttled` gives a 429 with `Retry-After` — clients already handle that from the throttle layer, so a quota rejection needs no new client behaviour.
24
`F("calls") + 1` increments in the database rather than reading into Python and writing back, so the value cannot go stale between the read and the write.

Why this works: Quotas end up on invoices and in support conversations, so the count has to survive a cache flush and a concurrent burst. A row, a lock, and a check constraint give that; a cache key with a TTL does not.

Layered defence — each layer catches what the one above cannot see

Edge — CDN / WAF

volumetric floods and known-bad sources, dropped before any Python process is involved

DRF throttles

per-IP and per-account pacing; approximate by design, and the right tool for scraping and brute force

Identity controls

account lockout keyed on the username, so credential stuffing spread across thousands of IPs still trips

Response uniformity

reset, signup and login answer the same whether or not the account exists — closes enumeration a throttle cannot

Query cost limits

bounded ranges, capped pagination depth, statement_timeout — for abuse that needs one request, not many

Quota accounting

a transactional row per account per period; exact, durable, and defensible on an invoice

  1. Edge — CDN / WAF — volumetric floods and known-bad sources, dropped before any Python process is involved
  2. DRF throttles — per-IP and per-account pacing; approximate by design, and the right tool for scraping and brute force
  3. Identity controls — account lockout keyed on the username, so credential stuffing spread across thousands of IPs still trips
  4. Response uniformity — reset, signup and login answer the same whether or not the account exists — closes enumeration a throttle cannot
  5. Query cost limits — bounded ranges, capped pagination depth, statement_timeout — for abuse that needs one request, not many
  6. Quota accounting — a transactional row per account per period; exact, durable, and defensible on an invoice

Which control catches which abuse

Which control catches which abuse
AbuseVolume?The control that actually works
Scraping the whole cataloguehighedge rate limiting + per-account throttle + pagination depth caps
Credential stuffinghigh, spread across IPsaccount lockout keyed on the *username*, not the IP
Account enumeration via resetlow — 3 requestsidentical responses and timing; a throttle cannot help
Expensive query abuselow — 1 requestquery complexity limits and `statement_timeout`
Overusing a paid plananya transactional quota row, checked before the work
Signup spammediumCAPTCHA or proof-of-work after the first failure signal, plus email verification

Together

python
if usage.calls >= usage.limit:
    raise Throttled(detail="Monthly quota exhausted.", wait=seconds_until_period_end())

Remember: Rate limiting is one layer. Volumetric abuse belongs at the edge, credential stuffing needs lockout keyed on the username rather than the IP, enumeration needs identical responses and timings, and single-request abuse needs query cost limits — none of which a request counter can express. Keep quotas separate from throttles: a throttle shapes traffic and may be approximate, while a quota is accounting and must be exact, so it lives in a transactional row with a database constraint. Raise `Throttled` for it so clients see the familiar 429 and `Retry-After`.

See also: throttle backends and multiple instances · anon user and scoped throttling · row level locking · functional indexes

Advertisement