Skip to content

Commit

Permalink
feat(api): user-specific daily rate limit
Browse files Browse the repository at this point in the history
  • Loading branch information
peterthomassen committed Jun 28, 2024
1 parent d6c4833 commit b4f922d
Show file tree
Hide file tree
Showing 6 changed files with 126 additions and 4 deletions.
2 changes: 1 addition & 1 deletion api/api/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@
"ALLOWED_VERSIONS": ["v1", "v2"],
"DEFAULT_THROTTLE_CLASSES": [
"desecapi.throttling.ScopedRatesThrottle",
"rest_framework.throttling.UserRateThrottle",
"desecapi.throttling.UserRateThrottle",
],
"DEFAULT_THROTTLE_RATES": { # When changing rate limits, make sure to keep docs/rate-limits.rst consistent
# ScopedRatesThrottle
Expand Down
4 changes: 1 addition & 3 deletions api/api/settings_quick_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,7 @@
}

REST_FRAMEWORK["PAGE_SIZE"] = 20
REST_FRAMEWORK["DEFAULT_THROTTLE_CLASSES"] = [
"rest_framework.throttling.UserRateThrottle"
]
REST_FRAMEWORK["DEFAULT_THROTTLE_CLASSES"] = ["desecapi.throttling.UserRateThrottle"]
REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = {"user": "1000/s"}

# Carry email backend connection over to test mail outbox
Expand Down
18 changes: 18 additions & 0 deletions api/desecapi/migrations/0038_user_throttle_daily_rate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2024-06-17 21:19

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("desecapi", "0037_remove_tokendomainpolicy_perm_dyndns"),
]

operations = [
migrations.AddField(
model_name="user",
name="throttle_daily_rate",
field=models.PositiveIntegerField(null=True),
),
]
1 change: 1 addition & 0 deletions api/desecapi/models/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def _limit_domains_default():
)
needs_captcha = models.BooleanField(default=True)
outreach_preference = models.BooleanField(default=True)
throttle_daily_rate = models.PositiveIntegerField(null=True)

objects = MyUserManager()

Expand Down
77 changes: 77 additions & 0 deletions api/desecapi/tests/test_throttling_user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
from unittest import mock
import time

from django.core.cache import cache
from django.test import TestCase, override_settings
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.test import APIRequestFactory, force_authenticate

from desecapi.models import User


class MockView(APIView):
@property
def throttle_classes(self):
# Need to import here so that the module is only loaded once the settings override is in effect
from desecapi.throttling import UserRateThrottle

return (UserRateThrottle,)

def get(self, request):
return Response("foo")


class ThrottlingTestCase(TestCase):
"""
Based on DRF's test_throttling.py.
"""

def setUp(self):
super().setUp()
self.factory = APIRequestFactory()

def _test_requests_are_throttled(self, counts, user=None):
cache.clear()
request = self.factory.get("/")
if user is not None:
force_authenticate(request, user=user)
with override_settings(
REST_FRAMEWORK={"DEFAULT_THROTTLE_RATES": {"user": "10/d"}}
):
view = MockView.as_view()
sum_delay = 0
for delay, count, max_wait in counts:
sum_delay += delay
with mock.patch(
"desecapi.throttling.UserRateThrottle.timer",
return_value=time.time() + sum_delay,
):
for _ in range(count):
response = view(request)
self.assertEqual(response.status_code, status.HTTP_200_OK)

response = view(request)
self.assertEqual(
response.status_code, status.HTTP_429_TOO_MANY_REQUESTS
)
self.assertTrue(
max_wait - 1 <= float(response["Retry-After"]) <= max_wait
)

def test_requests_are_throttled_unauthenticated(self):
self._test_requests_are_throttled([(0, 10, 86400)])

def test_requests_are_throttled_user(self):
for email, throttle_daily_rate in [
("[email protected]", None),
("[email protected]", 3),
("[email protected]", 30),
]:
user = User.objects.create_user(
email=email, password="", throttle_daily_rate=throttle_daily_rate
)
self._test_requests_are_throttled(
[(0, throttle_daily_rate or 10, 86400)], user=user
)
28 changes: 28 additions & 0 deletions api/desecapi/throttling.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,31 @@ def THROTTLE_RATES(self):
def get_cache_key(self, request, view):
key = super().get_cache_key(request, view)
return [f"{key}_{duration}" for duration in self.duration]


class UserRateThrottle(throttling.UserRateThrottle):
"""
Like DRF's UserRateThrottle, but supports individual rates per user.
"""

def __init__(self):
pass # defer to allow_request() where request object is available

def allow_request(self, request, view):
self.request = request
super().__init__() # gets and parses rate
return super().allow_request(request, view)

def get_rate(self):
try:
return f"{self.request.user.throttle_daily_rate:d}/d"
except (
AttributeError, # request.user is AnonymousUser
TypeError, # .throttle_daily_rate is None
):
return super().get_rate()

# Override the static attribute of the parent class so that we can dynamically apply override settings for testing
@property
def THROTTLE_RATES(self):
return api_settings.DEFAULT_THROTTLE_RATES

0 comments on commit b4f922d

Please sign in to comment.