Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions src/apps/api/tests/test_throttling.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
from unittest.mock import patch

from django.core.cache import cache
from django.test import TestCase, override_settings
from rest_framework import status
from rest_framework.test import APIClient
from rest_framework.throttling import SimpleRateThrottle

from factories import CompetitionFactory, UserFactory

# SimpleRateThrottle subclasses (AnonRateThrottle/UserRateThrottle/ScopedRateThrottle and
# their AnonBurstRateThrottle/UserBurstRateThrottle counterparts in api/throttling.py) read
# REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'] once at import time into this shared class
# attribute, so @override_settings(REST_FRAMEWORK=...) does not reach it. Tests patch it
# directly to get small, fast limits instead of waiting out the real ones.
#
# Every scope referenced by an active throttle class must be present in the patched dict
# (missing keys raise ImproperlyConfigured), so tests start from generous defaults for all
# scopes and only tighten the one(s) under test.
PUBLIC_URL = "/api/competitions/public/"
LIST_URL = "/api/competitions/"

GENEROUS_RATES = {
"anon": "1000/day",
"user": "1000/day",
"anon_burst": "1000/min",
"user_burst": "1000/min",
"competitions_public": "1000/day",
}


@override_settings(CACHES={
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "throttle-test-cache",
},
})
class ThrottlingTests(TestCase):
def setUp(self):
self.client = APIClient()
cache.clear()
CompetitionFactory(published=True)

def tearDown(self):
cache.clear()

@patch.object(SimpleRateThrottle, "THROTTLE_RATES", {**GENEROUS_RATES, "competitions_public": "2/min"})
def test_public_action_throttles_after_scope_limit(self):
for _ in range(2):
response = self.client.get(PUBLIC_URL)
self.assertEqual(response.status_code, status.HTTP_200_OK)

response = self.client.get(PUBLIC_URL)
self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS)
self.assertIn("Retry-After", response)

@patch.object(SimpleRateThrottle, "THROTTLE_RATES", {**GENEROUS_RATES, "competitions_public": "1/min"})
def test_public_action_throttle_is_independent_of_global_anon_throttle(self):
# Exhaust the competitions_public scope only.
self.assertEqual(self.client.get(PUBLIC_URL).status_code, status.HTTP_200_OK)
self.assertEqual(self.client.get(PUBLIC_URL).status_code, status.HTTP_429_TOO_MANY_REQUESTS)

# The global 'anon' bucket (used by list/retrieve) is untouched.
response = self.client.get(LIST_URL)
self.assertEqual(response.status_code, status.HTTP_200_OK)

@patch.object(SimpleRateThrottle, "THROTTLE_RATES", {**GENEROUS_RATES, "anon": "2/min"})
def test_list_action_uses_global_anon_throttle(self):
for _ in range(2):
response = self.client.get(LIST_URL)
self.assertEqual(response.status_code, status.HTTP_200_OK)

response = self.client.get(LIST_URL)
self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS)

@patch.object(SimpleRateThrottle, "THROTTLE_RATES", {**GENEROUS_RATES, "user": "2/min"})
def test_list_action_uses_global_user_throttle_for_authenticated_requests(self):
self.client.force_authenticate(user=UserFactory(username="throttled-user"))

for _ in range(2):
response = self.client.get(LIST_URL)
self.assertEqual(response.status_code, status.HTTP_200_OK)

response = self.client.get(LIST_URL)
self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS)

@patch.object(SimpleRateThrottle, "THROTTLE_RATES", {**GENEROUS_RATES, "competitions_public": "1/min"})
def test_public_action_counts_anonymous_and_authenticated_requests_separately(self):
# Anonymous identity (keyed by IP) exhausts its own bucket.
self.assertEqual(self.client.get(PUBLIC_URL).status_code, status.HTTP_200_OK)
self.assertEqual(self.client.get(PUBLIC_URL).status_code, status.HTTP_429_TOO_MANY_REQUESTS)

# A different, authenticated identity (keyed by user id) has its own separate bucket.
self.client.force_authenticate(user=UserFactory(username="another-user"))
self.assertEqual(self.client.get(PUBLIC_URL).status_code, status.HTTP_200_OK)

@patch.object(SimpleRateThrottle, "THROTTLE_RATES", {**GENEROUS_RATES, "anon_burst": "2/min"})
def test_list_action_uses_global_anon_burst_throttle(self):
for _ in range(2):
response = self.client.get(LIST_URL)
self.assertEqual(response.status_code, status.HTTP_200_OK)

response = self.client.get(LIST_URL)
self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS)

@patch.object(SimpleRateThrottle, "THROTTLE_RATES", {**GENEROUS_RATES, "user_burst": "2/min"})
def test_list_action_uses_global_user_burst_throttle_for_authenticated_requests(self):
self.client.force_authenticate(user=UserFactory(username="burst-user"))

for _ in range(2):
response = self.client.get(LIST_URL)
self.assertEqual(response.status_code, status.HTTP_200_OK)

response = self.client.get(LIST_URL)
self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS)

@patch.object(SimpleRateThrottle, "THROTTLE_RATES", {**GENEROUS_RATES, "anon_burst": "2/min"})
def test_public_action_throttled_by_anon_burst_even_with_generous_daily_scope(self):
# competitions_public (daily) has plenty of room; the burst throttle stacked
# onto the `public` action (api/views/competitions.py) should still trip.
for _ in range(2):
response = self.client.get(PUBLIC_URL)
self.assertEqual(response.status_code, status.HTTP_200_OK)

response = self.client.get(PUBLIC_URL)
self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS)

@patch.object(SimpleRateThrottle, "THROTTLE_RATES", {**GENEROUS_RATES, "user_burst": "2/min"})
def test_public_action_throttled_by_user_burst_even_with_generous_daily_scope(self):
self.client.force_authenticate(user=UserFactory(username="public-burst-user"))

for _ in range(2):
response = self.client.get(PUBLIC_URL)
self.assertEqual(response.status_code, status.HTTP_200_OK)

response = self.client.get(PUBLIC_URL)
self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS)
9 changes: 9 additions & 0 deletions src/apps/api/throttling.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle


class AnonBurstRateThrottle(AnonRateThrottle):
scope = 'anon_burst'


class UserBurstRateThrottle(UserRateThrottle):
scope = 'user_burst'
13 changes: 12 additions & 1 deletion src/apps/api/views/competitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.renderers import JSONRenderer
from rest_framework.throttling import ScopedRateThrottle
from rest_framework_csv.renderers import CSVRenderer
from api.throttling import AnonBurstRateThrottle, UserBurstRateThrottle
from api.pagination import DynamicChoicePagination, LargePagination
from api.renderers import ZipRenderer
from rest_framework.viewsets import ModelViewSet
Expand All @@ -42,6 +44,10 @@
class CompetitionViewSet(ModelViewSet):
queryset = Competition.objects.all()
permission_classes = (AllowAny,)
# Declared so the `public` action's @action(throttle_scope=...) kwarg passes
# ViewSetMixin.as_view()'s hasattr(cls, key) check; ScopedRateThrottle reads it
# via getattr(view, 'throttle_scope', None) regardless of this default.
throttle_scope = None

def get_queryset(self):

Expand Down Expand Up @@ -568,7 +574,12 @@ def create_dump(self, request, pk=None):
serializer = CompetitionCreationTaskStatusSerializer({"status": "Success. Competition dump is being created."})
return Response(serializer.data, status=201)

@action(detail=False, methods=('GET',), pagination_class=LargePagination)
@action(
detail=False, methods=('GET',),
pagination_class=LargePagination,
throttle_classes=[ScopedRateThrottle, AnonBurstRateThrottle, UserBurstRateThrottle],
throttle_scope='competitions_public',
)
def public(self, request):
"""
Retrieve a public list of published competitions with optional filtering and ordering.
Expand Down
13 changes: 13 additions & 0 deletions src/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,19 @@
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
'DEFAULT_THROTTLE_CLASSES': (
'rest_framework.throttling.AnonRateThrottle',
'rest_framework.throttling.UserRateThrottle',
'api.throttling.AnonBurstRateThrottle',
'api.throttling.UserBurstRateThrottle',
),
'DEFAULT_THROTTLE_RATES': {
'anon': '100/day',
'user': '1000/day',
'anon_burst': '60/min',
'user_burst': '300/min',
'competitions_public': '300/day',
},
'DATETIME_INPUT_FORMATS': (
'iso-8601',
'%B %d, %Y',
Expand Down