Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support and test for async #300

Open
wants to merge 5 commits into
base: main
Choose a base branch
from

Conversation

daniel-brenot
Copy link

Added an example test and general async support. Should work well

Copy link

@mlissner mlissner left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, I have a few comments, but I'm not actually part of this project, and I don't have a ton of confidence in my review, but I thought I'd chime in, in case it helps.

One big picture comment though is that I think the duplication can be eliminated pretty easily?

There are only three spots where we check/increment/set the cache. Why not just do an if/else around each of those. You need to do that anyway, since not every cache has async, so you could do:

if is_async and hasattr(cache, "aadd"):
    await do_async_cache_thing()
else:
    do_regular_cache_thing()

Could that work?

django_ratelimit/core.py Outdated Show resolved Hide resolved
# python3-memcached will throw a ValueError if the server is
# unavailable or (somehow) the key doesn't exist. redis, on the
# other hand, simply returns None.
count = cache.incr(cache_key)
Copy link
Author

@daniel-brenot daniel-brenot Aug 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but there is no aincr that i've seen

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like aincr and adecr have been around since ~4.0 but the implementation isn't atomic, even in caches that should support atomic incr/decr. That's going to be a challenge, even if it gets fixed. I think this is the best choice for now, even if it

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if they are around now then i could add the if statement to the call as well. That will let us support them if the implementation becomes atomic in the future.

django_ratelimit/core.py Outdated Show resolved Hide resolved
# python3-memcached will throw a ValueError if the server is
# unavailable or (somehow) the key doesn't exist. redis, on the
# other hand, simply returns None.
count = cache.incr(cache_key)
Copy link
Author

@daniel-brenot daniel-brenot Aug 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but there is no aincr that i've seen

@mlissner
Copy link

mlissner commented Jan 9, 2024

This looks fine to me. I'd love to have this merged and released so we can add ratelimits back on our views.

@daniel-brenot
Copy link
Author

@mlissner Anything left for this to be merged?

@mlissner
Copy link

@daniel-brenot I'm not a maintainer, I think we need @jsocol to merge, if they're satisfied with this...

Copy link
Owner

@jsocol jsocol left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like Django 3.2 did have some failures that'll prevent merging this exactly as-is.

Looking through the Django cache source, I wonder if there might be a simpler and less duplicative option, which is something like:

async def aratelimit(request, *args, *kwargs):
    old_limited = getattr(request, 'limited', False)
    ratelimited = sync_to_async(is_ratelimited, ...)
    # ... etc ...

and since that doesn't rely on the a* methods from the cache, it sidesteps the issue with aincr not being atomic

# python3-memcached will throw a ValueError if the server is
# unavailable or (somehow) the key doesn't exist. redis, on the
# other hand, simply returns None.
count = cache.incr(cache_key)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like aincr and adecr have been around since ~4.0 but the implementation isn't atomic, even in caches that should support atomic incr/decr. That's going to be a challenge, even if it gets fixed. I think this is the best choice for now, even if it

if is_async:
async def inner():
try:
# Some caches don't have an async implementation
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like the aadd methods are defined in BaseCache (see the next comment down for the link) so in theory any cache should have access to them

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've tried it before, and if someone is using an old cache this can fail. This is just to support caches that may not define the cache interface with those methods. Test implementations can also be missing this.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you clarify what you mean by "old cache"? Is it an implementation that doesn't inherit from BaseCache?

Comment on lines 15 to +17
def ratelimit(group=None, key=None, rate=None, method=ALL, block=True):
def decorator(fn):
@wraps(fn)
def _wrapped(request, *args, **kw):
old_limited = getattr(request, 'limited', False)
ratelimited = is_ratelimited(request=request, group=group, fn=fn,
key=key, rate=rate, method=method,
increment=True)
request.limited = ratelimited or old_limited
if ratelimited and block:
cls = getattr(
settings, 'RATELIMIT_EXCEPTION_CLASS', Ratelimited)
raise (import_string(cls) if isinstance(cls, str) else cls)()
return fn(request, *args, **kw)
if iscoroutinefunction(fn):
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not something to change here, but I wonder if having a second aratelimit decorator might be simpler / clearer. I know we've only got 3 months of 3.2 official support left, but I think it might make it easier to say "aratelimit requires Django >= 4". "Explicit is better than implicit," after all

Copy link

@mlissner mlissner Jan 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Async is barely complete even in Django 5.0, so I doubt anybody would care if 3.2 doesn't support the async decorator. If you're doing async work, you're probably running the latest version of Django.

@mlissner
Copy link

Thank you for the comments @jsocol! I truly appreciate your benevolent maintenance of this library.

@benjaoming
Copy link
Contributor

Looks like Django 3.2 did have some failures that'll prevent merging this exactly as-is.

Isn't it specifically Django 3.2 on Python 3.7 that has the failure? The tests running in Python 3.8+ seem to pass? 🤔

@daniel-brenot
Copy link
Author

Looks like Django 3.2 did have some failures that'll prevent merging this exactly as-is.

Looking through the Django cache source, I wonder if there might be a simpler and less duplicative option, which is something like:

async def aratelimit(request, *args, *kwargs):
    old_limited = getattr(request, 'limited', False)
    ratelimited = sync_to_async(is_ratelimited, ...)
    # ... etc ...

and since that doesn't rely on the a* methods from the cache, it sidesteps the issue with aincr not being atomic

I think we should use the async versions where possible, even if they aren't atomic yet. If they become atomic in the future, then the work is already done.

As for using sync to async, i'd prefer to avoid that if possible. It may be an easier solution, but if any of the async methods become better in the future we lose out on the performance improvement. Given that this may be used on methods called often, this could make a substantial difference in performance to a server.

@jsocol
Copy link
Owner

jsocol commented Jan 24, 2024

I think we should use the async versions where possible, even if they aren't atomic yet. If they become atomic in the future, then the work is already done.

An atomic increment operation is, unfortunately, very much a requirement for this library to work correctly. (See the discussion on #255 regarding the database cache backend.) Without either atomic increment or check-and-set, requests will be under-counted. Two requests that read the same current value will both set the same "next" value.

Isn't it specifically Django 3.2 on Python 3.7 that has the failure? The tests running in Python 3.8+ seem to pass? 🤔

100% @benjaoming—I was being a little lazy, saw the Dj3.2-Py3.7 failure and didn't keep searching. Looks like just this combo and some minor linting issues.

@mlissner
Copy link

I'm pretty lazy here, but if it's just...

Django 3.2 on Python 3.7 that has the failure

...I still think the thing to do is just say async isn't supported on django 3.2 and block off the code path. 3.2 didn't have much async support at all, and it's only got three months of support left despite being a LTS.

(Sorry to not have more meaningful code comments. :) )

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants