I have a new, hopefully better, idea of how ratelimit can work, internally, so I want to write it down here, and get @willkg's opinion and sanity check (also also if anyone else has comments) before I dive into it.
Problems
Multiple cache keys per decorator
Right now, each @ratelimit
decorator can create several keys, all of which are then treated with the same expiration (and see below about that). Except, if you stack decorators, they may generate the same key (e.g. for the IP address) and then things just stop making any sense at all.
So what we're doing is updating counters but in a really non-intuitive way, and we break stacking, which seems to be a natural way people try to use the library. It also costs us atomicity because we can't use cache.incr
.
Good actors can get stuck in sliding windows
Because we push back the TTL on every increment, once a client gets ratelimited, they are stuck until they wait the full period to reset, so if the limit is 1/h
and they wait 59 minutes, they then have to wait another hour, not just one minute, because they jumped the gun a little.
Per-method (or group) is a pain
Implementing per-method or method-group rate limits would require something keys=lambda r: 'group'+r.META['REMOTE_ADDR']
everywhere.
Solution
This is a big, backwards-incompatible change. Fortunately, it's pre-1.0, so whatever. This would, hopefully, be a step toward something we'd call 1.0.
One counter per decorator
The biggest change: each decorator should result in a single counter (cache key, whatever). So
def user_or_ip(r):
u = request.user
return u.getusername() if u.is_authenticated() else r.META['REMOTE_ADDR']
@ratelimit(key=user_or_ip, rate='100/h')
def some_view(request):
Would result in exactly one counter, that uses a combination of the method name, the key value, and the rate (and probably the current time, but hang on) probably MD5ed together to get a name.
Then, if you wanted to do, say, a burst limit, you could do:
@ratelimit(key=user_or_ip, rate='10/s')
@ratelimit(key=user_or_ip, rate='100/h')
def some_view(request):
Since the rate is part of the key, these two get incremented independently. And if the next method had:
@ratelimit(key=user_or_ip, rate='10/s')
@ratelimit(key=user_or_ip, rate='100h')
def another_view(request):
the default behavior would be to ratelimit these views independently.
Fixed windows
I've come around to the view that each attempt shouldn't completely reset the clock on the timer. We should be creating windows. The window needs to be staggered somehow by key, so that we don't, for example, open all the flood gates every hour on the hour. (We can definitely skip staggering that for seconds and possibly for minutes.)
So, for example, if the rate is 100/h
then we'd do something like:
def k(value, period=3600):
ts = int(time.time())
m = ts - (ts % period) + (zlib.crc32(value) % period)
if m < ts:
return m + period
return m
Then we append the counter name/cache key with this value. The value should be different for every key and should change every period
, but its staggered within the period
according to the wall clock.
We can even somehow return this to the view to allow it to send Retry-After
if we want.
The new signature
It should be simpler, and better reflect that each decorator is an individual counter, while still providing shortcuts for common use cases:
@ratelimit(
group=None,
key=None,
rate=None,
method=['POST'],
block=False)
Most of this should be straightforward, but:
group
defaults to the dotted name of the method (e.g. myapp.views.myview
). That limits each view individually, but you can set it to, e.g. group='myviewgroup'
to count a number of views together.
rate
works as now ('X/Yu'
where X
and Y
are integers and u
is a unit from {s,m,h,d}
) or rate
is a callable (or a dotted path to a callable) that is passed the group and the request and returns either a rate string, or a tuple: (limit, period-in-secods)
. I think this is a better method of handling skip_if
because the callable could return None
for "no limit" (or 0
for "never allow). And, it opens up a whole new thing that would be, I think, very useful (see below).
key
is one of a few well-known strings, a callable, or a dotted path to a callable. Callables would get the group and the request. Well-known strings would include at least:
'ip'
- request.META['REMOTE_ADDR]
get:X
- request.GET['X']
post:X
- request.POST['X']
field:X
- d = request.POST if request.method == 'POST' else request.GET; d['X']
header:what-ever
- request.META['HTTP_WHAT_EVER']
user
- request.user
user_or_ip
- request.user if request.user.is_authenticated() else request.META['REMOTE_ADDR']
(very common use case, nice to have)
method
works as now, a method or a list of methods, or None
for all
block
works as now, True
to raise a Ratelimited
exception, False
to annotate the request.
Generating the counter name/cache key
We combine all of this to get a key that we increment:
cache_key = PREFIX + md5(group + rate + key_value + window)
We don't worry about expiring it. We just do limited = cache.incr(cache_key) > limit
and call it good. The values age out of the LRU.
Current use cases
Login forms
A very common form to protect right now is a login form, which can be done with one decorator:
# old
@ratelimit(ip=true, field='username', rate='5/m')
# new
@ratelimit(key='ip', rate='10/m')
@ratelimit(key='field:username', rate='5/m')
You'll need two decorators to provide the same (one-IP/many-users and many-IPs/one-user) protections, but you get more control. If you expect users to be behind NAT, you can allow a higher single-IP rate, while still preventing dictionary attacks against a username.
New stuff
This opens up some cool stuff. Stacking now works as intended/expected, like in the burst rate examples above. But there's more that could happen in subsequent versions:
Callable rates
A pretty trivial use case for callable rates is customizing them by user or user type.
def get_rate_limit(group, request):
if request.user.is_authenticated():
return '1000/h'
return '100/h'
A cool thing to build in would be per-user limiting, e.g.:
class UserRateLimit(models.Model):
group = models.CharField(db_index=True)
user = models.ForeignKeyField(null=True) # 'null' for default
rate = models.CharField()
@classmethod
def get_for_user(cls, group, request):
user = request.user
if not user.is_authenticate():
rate = cls.objects.get(group=group, user=None)
return rate.rate
try:
rate = cls.objects.get(group=group, user=user)
return rate.rate
except cls.DoesNotExist:
default = cls.objects.get(group=group, user=None)
return default.rate
(Or something, maybe better supporting anonymous users with user=0
or similar. And of course with caching.)
Definitions in settings
Once group
is a thing, it becomes easy to do something like this:
# settings.py
RATE_LIMIT_CONFIG = {
'*': { # global default
'key': 'user_or_ip',
'rate': '10/m',
},
'some_group': {
'key': 'ip',
'rate': '100/m',
},
}
The decorator (or helper methods) could pull these settings unless they're specifically overridden by the invocation.
That makes it less error-prone to use a helper like is_ratelimited
and also change things (like temporary remove limits) in settings without messing with actual source modules.