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](https://github.com/jsocol/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](https://docs.djangoproject.com/en/4.1/topics/cache/) as a backend, and will keep track of usage for a particular `key`. ```python # 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. ```python 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. ```python 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_code=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`. ```python 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](https://docs.djangoproject.com/en/4.1/topics/cache/), 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.