Guide 2023 By davegaeddert

Rate limiting requests in Django

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 (Forge will use Redis if you simply enable it on Heroku), 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 HttpResponse 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 HttpResponse(
                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.

Why not make this a Forge package?

Thought about it! In reality though, it's only 43 lines of code and it's almost easier for everybody to just copy and paste it into their project. There's not much here that can break (and need updating), and you can easily customize it to your needs.

I may make it a part of Forge "core" in the future, but most projects don't need rate limiting.

Feedback

Questions or ideas for improvement? Open a GitHub Discussion →