skip to content

In Django REST Framework, how would you give a public search endpoint its own tighter limit using ScopedRateThrottle and throttle_scope?

level: middleimportance: should knowfreq 42%

answer

  1. a named bucket per view
  2. an attribute on the view
  3. the rate lives in the same settings dict
  4. all listed throttles are consulted

basics

~20 s

Add ScopedRateThrottle to the throttle classes, set throttle_scope = 'search' on the search view, and define a 'search' rate in DEFAULT_THROTTLE_RATES. Only views with a throttle_scope are limited, each scope counted separately per user or IP.

solid answer

~40 s

`ScopedRateThrottle` reads the view's `throttle_scope` at request time and looks its rate up in `DEFAULT_THROTTLE_RATES`, so `throttle_scope = "search"` plus `"search": "20/min"` gives search its own counter, keyed by user primary key or, for anonymous callers, by IP. Views without `throttle_scope` are not touched by it, so it can sit in `DEFAULT_THROTTLE_CLASSES` next to the anon and user classes. DRF asks **every** listed throttle, so a search request also counts against the general `anon` or `user` allowance, and the reported wait is the longest among the refusing classes. A scope without a configured rate raises `ImproperlyConfigured` at request time. Function views get the scope from the `@throttle_scope` decorator, added in DRF 3.18.0. For burst-plus-sustained limits, subclass `UserRateThrottle` twice with different `scope` names.

code

python · 13 lines
python
from rest_framework import generics

from .models import Article
from .serializers import ArticleSerializer


class ArticleSearchView(generics.ListAPIView):
    serializer_class = ArticleSerializer
    throttle_scope = "search"

    def get_queryset(self):
        term = self.request.query_params.get("q", "")
        return Article.objects.filter(title__icontains=term)

go deeper

for a junior

Know that throttle_scope on a view plus a matching rate in DEFAULT_THROTTLE_RATES gives that view its own limit.

for a middle

Explain how ScopedRateThrottle picks its rate at request time, how keys include the scope, and why all listed throttles are consulted.

for a senior

Design burst and sustained scopes for expensive endpoints, and watch for view-level throttle_classes that silently drop the global limits.

for a principal

Decide which endpoints deserve their own allowance and how rates are reviewed as traffic and query costs change.

## The problem: one endpoint is more expensive than the rest A public search endpoint — `GET /api/search/?q=...` — typically runs a heavier query than a detail lookup, and anonymous scrapers find it first. The global `anon` and `user` throttles in Django REST Framework (DRF) apply one allowance to the whole API, so they are either too loose for search or too tight for everything else. DRF's answer is a **scope**: a named allowance attached to particular views. ## How `ScopedRateThrottle` works `ScopedRateThrottle` postpones choosing its rate until it sees the view: 1. In `allow_request()` it reads `getattr(view, "throttle_scope", None)`. 2. If the view has no scope, it returns `True` — the class does nothing for that view. 3. Otherwise it fetches `DEFAULT_THROTTLE_RATES[scope]`; a missing key raises `ImproperlyConfigured` ("No default throttle rate set for 'search' scope"). 4. It builds the cache key `throttle_<scope>_<ident>`, where `ident` is `request.user.pk` for authenticated users and the client IP otherwise, and checks the stored history. Because scopes are part of the key, `search` and `uploads` counters for the same user never mix. ```python # settings.py REST_FRAMEWORK = { "DEFAULT_THROTTLE_CLASSES": [ "rest_framework.throttling.AnonRateThrottle", "rest_framework.throttling.UserRateThrottle", "rest_framework.throttling.ScopedRateThrottle", ], "DEFAULT_THROTTLE_RATES": { "anon": "1000/day", "user": "10000/day", "search": "20/min", }, } ``` ## Attaching the scope | View style | How to set the scope | |---|---| | `APIView` or generic view | class attribute `throttle_scope = "search"` | | `@api_view` function | `@throttle_scope("search")` under `@api_view` (DRF 3.18.0+) | | one action of a viewset | give search its own view, or override `get_throttles()` for that action | A dedicated `ListAPIView` for search is the simplest route: its scope, pagination and filtering are all visible in one class. ## Several throttles at once `APIView.check_throttles()` loops over **all** throttle instances; it does not stop at the first refusal. Consequences worth knowing: - A search request by an anonymous client is counted by `anon` **and** `search`; either can refuse it. - A throttle that allows the request records it even if another throttle refuses the same request, so refused requests can still consume the looser allowance. - When several refuse, the `Retry-After` value is the **largest** of their waits (ignoring any that cannot compute one). - `AnonRateThrottle` skips authenticated users, but `ScopedRateThrottle` does not: logged-in users are counted under `search` by user primary key. ## Burst and sustained limits Two scopes on one class family express "short bursts, but not all day": ```python from rest_framework.throttling import UserRateThrottle class SearchBurstThrottle(UserRateThrottle): scope = "search_burst" class SearchSustainedThrottle(UserRateThrottle): scope = "search_sustained" ``` List both on the search view and define `"search_burst": "10/s"` and `"search_sustained": "500/hour"`. Each subclass keeps its own key because the scope is part of it. ## Checklist for a scoped endpoint - Add `ScopedRateThrottle` once, globally; it is inert for unscoped views. - Define every scope's rate, or requests to that view fail with a configuration error. - Remember a view-level `throttle_classes` replaces the global list, so it must include the scoped class too. - Test that the 21st request in a minute answers 429 with `Retry-After`.

  • Why is a refused search request in DRF sometimes still counted against the user's daily allowance?
    `check_throttles()` asks every throttle before deciding. If the daily `user` throttle allows the request, it records the timestamp in its history, even though the `search` throttle then refuses it. A client hammering search can therefore burn part of its general allowance with requests that all returned 429.
  • In DRF, a search view sets throttle_classes = [ScopedRateThrottle] and throttle_scope = 'search'. What changed?
    The view's list replaces `DEFAULT_THROTTLE_CLASSES`, so the global `anon` and `user` throttles no longer run on it. Only the `search` scope applies. That may be intended, but it is a common accident when someone meant to add a limit rather than swap the whole list.

saying these in an interview costs you the question

  • ScopedRateThrottle limits every view once it is in DEFAULT_THROTTLE_CLASSES.
  • DRF stops checking throttles after the first one refuses.
  • A missing scope rate just leaves that view unthrottled.
  • ScopedRateThrottle ignores logged-in users, like AnonRateThrottle.
  • All scopes for one user share a single counter.