Architecture¶
Module map¶
graph TD
A[rates.py: parse_rate] --> D[algorithms.py]
B[keys.py] --> E[throttles.py: RateLimitThrottle / rate_limit]
C[tiers.py] --> E
D --> E
F[client.py: get_redis_client] --> E
E --> G[mixins.py: RateLimitHeadersMixin]
E --> H[decorators.py: @ratelimit]
I[settings.py] --> B
I --> F
rates.py— parses"100/m"-style strings into aRate(count, period).algorithms.py— the three Lua-script-based algorithms. Pure functions:(client, key, rate, cost, ...) -> LimitResult. No DRF imports at all.keys.py— built-in and composable key functions, all sharing the(request, view=None) -> strsignature.tiers.py— plan-tier rate resolution.client.py— resolves and caches the Redis client from settings.throttles.py—RateLimitThrottle, a realrest_framework.throttling.BaseThrottlesubclass, andrate_limit(), the factory that configures one.mixins.py—RateLimitHeadersMixin, adding informational headers to every response.decorators.py—@ratelimit()for function-based views.
Why atomicity is everything¶
Every algorithm here reduces to one EVAL call. Redis executes an entire
Lua script as a single atomic operation — no other command runs on that
Redis (or, in Cluster mode, that shard) in between reading the current
count/tokens and writing the updated value. This is what makes concurrent
requests with the same key impossible to double-count: there is no gap
between "check" and "increment" for a race to land in.
sequenceDiagram
participant ReqA as Request A (thread 1)
participant ReqB as Request B (thread 2)
participant Redis
ReqA->>Redis: EVAL token_bucket_script (key="user:42")
ReqB->>Redis: EVAL token_bucket_script (key="user:42")
Note over Redis: Redis executes one Lua script at a time -<br/>B's script does not start until A's has fully finished.
Redis-->>ReqA: allowed=true, tokens=4
Redis-->>ReqB: allowed=true, tokens=3
tests/test_concurrency.py proves this directly: many real OS threads
hammering the same key concurrently never collectively exceed the
configured limit, for all three algorithms.
Redis Cluster compatibility¶
Redis Cluster shards keys across nodes by hashing the key (or the portion
inside {}, if present) to one of 16384 slots. A multi-key operation
(including a Lua script referencing multiple KEYS[]) is only allowed
if every key involved hashes to the same slot — otherwise Redis returns a
CROSSSLOT error.
- Fixed window and token bucket each use exactly one key per identity — trivially single-slot, no special handling needed.
- Sliding window uses two keys (current and previous window) for the
same identity.
sliding_window_check()builds them asf"{{{key}}}:{window_index}"andf"{{{key}}}:{window_index - 1}"— wrapping the entire base key in a Redis Cluster hash tag ({...}). Redis Cluster's hash-slot algorithm considers only the substring inside{}when a key contains one, so both window keys always hash to the same slot regardless of the differing numeric suffix outside the braces. This is handled internally — no cooperation from the caller or fromrate_limit()'s own key-building is required.
Why RateLimitThrottle subclasses BaseThrottle directly¶
DRF already defines the throttle contract:
allow_request(request, view) -> bool and wait() -> float | None,
invoked by APIView.check_throttles(), which raises
rest_framework.exceptions.Throttled(wait) on denial — and DRF's own
exception_handler already sets the Retry-After header from
exc.wait. Building on this means:
- Nothing about request dispatch, exception-to-response conversion, or
header setting for the
429case needs to be reimplemented. rate_limit()-produced throttles compose with any otherBaseThrottlein the samethrottle_classeslist (DRF's ownUserRateThrottle, a custom one, ...) with no special interaction.- The one gap DRF doesn't cover — informational headers on a
successful response — is exactly what
RateLimitHeadersMixinadds, viafinalize_response(), DRF's own hook for exactly this kind of post-processing.
Why get_throttles() needs caching¶
DRF's get_throttles() constructs a fresh throttle instance from
throttle_classes on every call
([throttle() for throttle in self.throttle_classes]). If
RateLimitHeadersMixin.finalize_response() naively called
self.get_throttles() again to read last_result, it would inspect
different instances than the ones check_throttles() actually invoked
allow_request() on — always seeing last_result = None. The mixin's
get_throttles() override caches the instances on self (a view
instance handles exactly one request in DRF, so per-instance caching is
correctly scoped to "per request") — see tests/test_mixins.py for a
regression test of this exact failure mode.
Design principles¶
- Real DRF integration, not a parallel system.
Throttled,Retry-After,check_throttles()— all DRF's own, unmodified. - Pure algorithm functions.
algorithms.pyhas no DRF or Django dependency beyond what it needs to be called with a rate/cost/time — testable and reusable outside a request/response cycle entirely. - Explicit atomicity story per algorithm, documented and tested with genuine concurrency, not just asserted.