A Django Ninja partner endpoint uses AuthRateThrottle('600/m'), yet in production partners share one limit or exceed it freely - what do you check?
answer
- what string identifies the caller
- where the timestamps are stored
- one cache per process
- whose IP when anonymous
basics
~10 sCheck that str(request.auth) is unique per partner, since AuthRateThrottle hashes it; that CACHES is a shared backend rather than per-process LocMemCache; and, for IP fallbacks, NINJA_NUM_PROXIES. Throttles also share keys across operations.
solid answer
~40 s`AuthRateThrottle` stores a list of timestamps in Django's default cache under `throttle_auth_<sha256(str(request.auth))>`. If partners share one limit, `str(request.auth)` is not unique - `authenticate()` returns `True` or a constant, or `Partner.__str__` returns something several partners have. If partners exceed it, the counts are not shared: Django's default `CACHES` is `LocMemCache`, which lives inside each process, so every worker keeps its own history and the effective limit multiplies. The read-then-write update is also non-atomic, so bursts can slip past. The key contains only the scope and identity, not the operation, so two operations with different `AuthRateThrottle` rates share one history. For anonymous fallbacks, `NINJA_NUM_PROXIES` decides which `X-Forwarded-For` entry is the client. And failed authentication returns 401 before throttles run, so these limits never slow key guessing.
code
python · 16 linesfrom ninja.throttling import AuthRateThrottle
class BulkOrderThrottle(AuthRateThrottle):
scope = "partner_bulk" # separate history from other AuthRateThrottle users
def get_cache_key(self, request):
partner = request.auth
if partner is None:
return super().get_cache_key(request)
return self.cache_format % {"scope": self.scope, "ident": partner.pk}
@partners.post("/orders/bulk", throttle=BulkOrderThrottle("10/m"))
def bulk_orders(request):
return {"accepted": True}go deeper
Recall that Ninja throttles store their counts in Django's cache and identify callers by request.auth or IP.
Explain the cache key format and why the object returned by authenticate() needs a unique string form.
Diagnose shared buckets, per-process LocMemCache counts, cross-operation key sharing and spoofable X-Forwarded-For fallbacks from production symptoms.
Decide which limits the application enforces and which move to the edge, given that in-app throttles are approximate and blind to failed authentication.
## How AuthRateThrottle keeps count In **Django Ninja**, `AuthRateThrottle` inherits its algorithm from `SimpleRateThrottle`: 1. Build a cache key: `throttle_auth_<ident>`, where `ident` is the SHA-256 of `str(request.auth)` for an authenticated caller, or the client IP otherwise. 2. Read the list of earlier request timestamps from **Django's default cache** under that key. 3. Drop timestamps older than the period. 4. If the list is as long as the allowed count, refuse; otherwise insert the current time and write the list back with the period as its timeout. Every symptom below comes from one of those steps. ## Symptom: partners share one limit The identity is `str(request.auth)`, so it must differ per partner: - `authenticate()` returning `True` makes every partner's string `"True"` - one shared bucket; - returning the raw key works but puts secrets through `str()`; returning a `Partner` with a stable `__str__` such as `partner:<pk>` is cleaner; - a `__str__` returning a display name collides when two partners share a name. The fix is a unique `__str__`, or a subclass overriding `get_cache_key()` to use the partner's id. ## Symptom: partners exceed the limit - **Per-process cache**: Django's default `CACHES` uses `LocMemCache`, a dictionary inside each process. Four workers on three servers give twelve independent histories, so a 600/m limit behaves like up to 7,200/m. Throttling needs a cache backend all processes share. - **Non-atomic updates**: the read-modify-write in steps 2-4 is not atomic, so concurrent requests from one partner can all read the same short list and all be allowed. Ninja's docs call the result "fuzziness"; it is small at modest concurrency and grows with bursts. - **Wrong scope sharing**: the key holds only the scope and the identity, not the path. An operation with `AuthRateThrottle("10/m")` and another with `AuthRateThrottle("600/m")` read and write the **same** history, so heavy traffic on one eats into the other's count. A per-endpoint limit needs a subclass with its own `scope` (and a rate for it). ## Symptom: anonymous limits keyed on the wrong address The IP fallback reads `X-Forwarded-For` and `REMOTE_ADDR` according to the **`NINJA_NUM_PROXIES`** setting: | `NINJA_NUM_PROXIES` | identity used | |---|---| | `None` (default) | the whole `X-Forwarded-For` value if present, else `REMOTE_ADDR` | | `0` | `REMOTE_ADDR` only | | `n` | the address `n` entries from the right of `X-Forwarded-For` | With the default, a client that sends its own `X-Forwarded-For` gets a fresh bucket for every value it invents. Setting it to the number of trusted proxies in front of Django makes the fallback use the address the nearest trusted proxy saw. ## One instance, many requests A throttle instance is created once, when the module defining the operation is imported, and serves every request to that operation. `SimpleRateThrottle.allow_request()` keeps per-request working state - the key, the history list, the current time - in attributes on that shared instance before writing the history back. Under a threaded server, two requests on the same instance can interleave between those steps, which adds to the imprecision already caused by the non-atomic cache update. It is one more reason Ninja's documentation describes its throttling as approximate. To reproduce any of these symptoms before changing settings: 1. Run the production number of worker processes locally. 2. Send a burst from two partners with a small rate such as `5/m`. 3. Read the cache keys that appear and compare the counts per key and per process. ## What these throttles do not do - **They do not see failed authentication.** Auth runs first and a 401 is returned before any throttle, so guessing API keys is not slowed by `AuthRateThrottle` or `AnonRateThrottle` on that operation. - **They are not a security boundary.** Ninja's own docs say application-level throttling is not protection against brute force or denial of service; that belongs in front of Django. - **They only see requests that reached Django**, after the web server and any proxies. ## A checklist - Log the cache key for two partners and compare. - Confirm `CACHES["default"]` points to a shared backend in production settings. - Give per-endpoint limits their own scope. - Set `NINJA_NUM_PROXIES` to match the deployment. - Put credential-guessing protection outside these throttles.
- Why is AuthRateThrottle not a defence against someone guessing partner API keys?Ninja runs authentication before throttles. A wrong key makes the authenticators decline and the request ends as a 401 before any throttle records it, so the guesses are never counted. Limiting failed attempts needs its own counter inside the authenticator or protection in front of the application.
- Two Django Ninja operations use AuthRateThrottle('600/m') and AuthRateThrottle('10/m'). How do they interact for one partner?They share one cache key, `throttle_auth_<ident>`, because the key holds only scope and identity. Each request to either operation adds to the same history, and each operation compares that shared history with its own rate, so busy traffic on the 600/m endpoint quickly exhausts the 10/m one.
AuthRateThrottle works like a cloakroom attendant who tallies visits by the name written on each ticket: if every ticket just says guest, everyone shares one tally, and if each cloakroom keeps its own notebook, a visitor who uses a different door starts again from zero.
saying these in an interview costs you the question
- AuthRateThrottle keys partners by their API key, whatever authenticate() returns.
- Django's default cache is shared across all worker processes.
- Each operation's throttle keeps a separate count automatically.
- Throttles count failed logins, so they stop API-key guessing.
- The client IP is always REMOTE_ADDR unless you write custom code.