Rate limiting requests in Django
A simple rate limiter using your cache
For Django views that trigger expensive operations, you may want to use some basic rate limiting to prevent abuse or protect your server load.
There are some existing Django packages that provide this (like django-ratelimit), but personally I feel like they do too much, or have too many options.
Does it have to be this complicated? Let's make our own simple rate limiter! No dependency, just a class and an exception that you can copy and paste.
RateLimit
class
Our rate limiter will use the default Django cache as a backend,
and will keep track of usage for a particular key
.
# ratelimit.py
from datetime import timedelta
from django.core.cache import caches
from django.core.exceptions import PermissionDenied
class RateLimitExceeded(PermissionDenied):
def __init__(self, usage, limit):
self.usage = usage
self.limit = limit
super().__init__("Rate limit exceeded")
class RateLimit:
def __init__(self, *, key, limit, period, cache=None, key_prefix="rl:"):
self.key = key
self.limit = limit
if isinstance(period, timedelta):
# Can pass a timedelta for convenience
self.seconds = period.total_seconds()
else:
self.seconds = period
self.cache = cache or caches["default"]
self.key_prefix = key_prefix
def get_usage(self):
# Timeout will be set here if it didn't exist, with a starting value of 0
return self.cache.get_or_set(
self.key_prefix + self.key, 0, timeout=self.seconds
)
def increment_usage(self):
self.cache.incr(self.key_prefix + self.key, delta=1)
def check(self):
usage = self.get_usage()
if usage >= self.limit:
raise RateLimitExceeded(usage=usage, limit=self.limit)
self.increment_usage()
Take a minute to read through the code,
then save it to your project as ratelimit.py
.
Rate limiting a view
The key
is your identifier for the specific instance of a rate limit,
so if the rate limit is user-specific,
put the user's ID in the key!
The same goes for any other unique identifier (like an IP address, or object ID).
The only other arguments you need are limit
and period
.
So if you want the user to be able to make 10 requests per minute,
you would use limit=10
and period=60
.
When you're processing a request,
create a new RateLimit
instance and call check()
on it.
If the limit has been exceeded,
a RateLimitExceeded
exception will be raised and Django will return a 403 Forbidden response.
from ratelimit import RateLimit, RateLimitExceeded
class PanelDetailView(DetailView):
model = Panel
def post(self, request, *args, **kwargs):
self.object = self.get_object()
RateLimit(
key=f"{request.user.id}:panel:{self.object.uuid}",
limit=1,
period=60,
).check()
context = self.get_context_data(object=self.object)
return self.render_to_response(context)
For convenience,
you can also pass a timedelta
object for the period
argument,
so you could also use period=timedelta(hours=24)
.
Returning an HTTP 429
Because the RateLimitExceeded
exception is a subclass of PermissionDenied
,
the default Django error handler will return a 403 Forbidden
response.
If you want to return a 429 Too Many Requests
response instead,
you can catch the exception and return an Response
with the status code.
from ratelimit import RateLimit, RateLimitExceeded
class PanelDetailView(DetailView):
model = Panel
def post(self, request, *args, **kwargs):
self.object = self.get_object()
try:
RateLimit(
key=f"{request.user.id}:panel:{self.object.uuid}",
limit=1,
period=60,
).check()
except RateLimitExceeded as e:
return Response(
f"Rate limit exceeded. You have used {e.usage} requests, limit is {e.limit}.",
status=429,
)
context = self.get_context_data(object=self.object)
return self.render_to_response(context)
Using a different cache
To use a cache other than default
,
pass the cache
argument to the RateLimit
.
from django.core.cache import caches
RateLimit(
key=f"{request.user.id}:panel:{self.object.uuid}",
limit=1,
period=60,
cache=caches["my_cache"],
).check()
The expectation is that this will be one of your Django caches,
but as you can see in the RateLimit
class,
the only methods used are get_or_set
and incr
,
so you could technically pass anything that implements those methods.