Django API key rate limiting¶
A key can cap how many requests it's allowed to make per second, minute, hour, or day.
instance, raw_key = OrganizationAPIKey.generate_key(
name="Production API",
tenant=org,
rate_limit=1000,
rate_limit_window="minute",
)
Leave rate_limit unset (the default) for no limit — the same behavior
every key had before this field existed. rate_limit_window defaults to
"minute" and is only meaningful once rate_limit is set; the valid
values are "second", "minute", "hour", "day" (also available as
RateLimitWindow.SECOND / .MINUTE / .HOUR / .DAY).
Checking the limit¶
from django_tenant_apikeys.ratelimit import check_rate_limit
result = check_rate_limit(api_key)
result.allowed # bool
result.limit # the configured rate_limit, or None if unset
result.remaining # requests left in the current window
result.reset_at # Unix timestamp the window resets at
result.retry_after # seconds until reset -- only set when allowed is False
Calling check_rate_limit() records a request against the key's
counter and reports whether it's still within the limit — it's not a
read-only check. Call it once per request you actually want counted. If
rate_limit isn't set, it always returns allowed=True with every other
field None.
What happens when the limit is exceeded¶
Django REST Framework¶
from django_tenant_apikeys.permissions import HasAPIKeyScope, WithinRateLimit
class OrdersView(APIView):
authentication_classes = [TenantAPIKeyAuthentication]
permission_classes = [WithinRateLimit, HasAPIKeyScope]
required_scopes = ["orders:read"]
Once the limit is hit, WithinRateLimit raises DRF's own Throttled — a
429 response with a Retry-After header — instead of returning False
(which would produce DRF's default 403). It also sets
X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset on the
response, whether the request was allowed or not, via view.headers — the
same mechanism DRF itself uses for headers like Allow.
Django Ninja¶
from django_tenant_apikeys.ratelimit import check_rate_limit
@api.get("/orders")
def list_orders(request):
result = check_rate_limit(request.auth)
if not result.allowed:
return 429, {"detail": "rate limit exceeded", "retry_after": result.retry_after}
...
See Django Ninja for the full view, composed with IP and scope checks.
How the default backend counts requests¶
CacheRateLimitBackend is a fixed-window counter stored in Django's
cache framework — CACHES["default"], or whichever alias
TENANT_API_KEY_RATE_LIMIT_CACHE names.
It uses cache.add() (atomic set-if-absent) to seed a window's counter and
cache.incr() (atomic increment) to count each request, instead of a
get-then-set round trip:
That pattern races: two concurrent requests can both read the same starting
value and both write back count + 1, undercounting by one every time two
requests overlap. add() + incr() avoids it — both operations are atomic
on any real Django cache backend, so two requests racing to initialize the
same window can't both reset it to 1, and two requests racing to increment
it can't both clobber the same starting value.
Consistency depends on the cache backend¶
- LocMemCache (Django's default if
CACHESisn't configured) is per-process.add()/incr()are thread-safe within one process, but each worker process (gunicorn, uWSGI, ...) has its own memory — multiple workers each enforce the limit independently rather than sharing one count. Fine for local development or a genuinely single-process deployment; not a real limit across workers. - Memcached, or a shared Redis (via
django-redisor similar), makes the exact same code correct across processes, because both backends implementadd/incratomically on the server, not just in Django's process. No package changes needed — pointCACHES["default"](or whateverTENANT_API_KEY_RATE_LIMIT_CACHEnames) at one of them.
Fixed window, not sliding¶
Being a fixed window rather than a sliding one, a key can burst up to roughly 2x its limit across a window boundary — a full window's quota right before it rolls over, then another full window's quota right after. A sliding window would avoid that, at the cost of storing a timestamp per request instead of a single counter — real complexity this default doesn't carry. If that trade-off doesn't work for your use case, see the custom backend section below.
Tenant isolation¶
The cache key check_rate_limit() builds includes both the concrete
model's class name and the key's primary key:
Two tenants with identically-configured keys (same rate_limit, even the
same name) never share a counter — each key row has its own primary key,
and each key's usage only ever affects its own count. The class name guards
against a narrower edge case: two different concrete
AbstractTenantAPIKey subclasses in the same project (say, an
OrganizationAPIKey and a PartnerAPIKey) whose primary keys happen to
collide numerically, which would otherwise share a cache key and a counter
that belongs to a different model's row entirely.
A custom backend¶
RateLimitBackend is a Protocol — anything with a matching hit() method
works:
class RateLimitBackend(Protocol):
def hit(self, key: str, limit: int, window_seconds: int) -> RateLimitResult: ...
Point TENANT_API_KEY_RATE_LIMIT_BACKEND at a dotted path to your own
class (constructed with no arguments) to replace CacheRateLimitBackend
entirely:
# myapp/ratelimit.py
from django_tenant_apikeys.ratelimit import RateLimitResult
class SlidingWindowRedisBackend:
def hit(self, key: str, limit: int, window_seconds: int) -> RateLimitResult:
...
This is the seam for a Redis-native sliding-window implementation, or
anything else the default cache-backed counter doesn't give you — nothing
in check_rate_limit(), WithinRateLimit, or the Ninja integration needs
to change to use it.
Next¶
- IP restrictions — the check that runs immediately before rate limiting in the recommended DRF/Ninja order.
- Configuration —
TENANT_API_KEY_RATE_LIMIT_CACHEandTENANT_API_KEY_RATE_LIMIT_BACKENDin the full settings reference. - Security — what rate limiting does and doesn't protect against (it isn't brute-force protection on the authentication step itself).