From 620c422b1f89edf46a57dc8a52508f6f853fddf8 Mon Sep 17 00:00:00 2001 From: Ihsan Ullah Date: Mon, 3 Aug 2026 22:58:58 +0500 Subject: [PATCH 1/2] Set default throttle rates of 100/day for anonymous requests and 1000/day for logged in users, plus a per-minute limit on top (20/min anon, 60/min user) so a burst of requests can't slip through under the daily cap. competitions/public/ gets its own separate daily limit (300/day) since it's hit a lot and shouldn't eat into the shared anon/user budget. Added tests for all of this. --- src/apps/api/tests/test_throttling.py | 137 ++++++++++++++++++++++++++ src/apps/api/throttling.py | 9 ++ src/apps/api/views/competitions.py | 13 ++- src/settings/base.py | 13 +++ 4 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 src/apps/api/tests/test_throttling.py create mode 100644 src/apps/api/throttling.py diff --git a/src/apps/api/tests/test_throttling.py b/src/apps/api/tests/test_throttling.py new file mode 100644 index 000000000..2cd3e46a5 --- /dev/null +++ b/src/apps/api/tests/test_throttling.py @@ -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) diff --git a/src/apps/api/throttling.py b/src/apps/api/throttling.py new file mode 100644 index 000000000..1fffe4187 --- /dev/null +++ b/src/apps/api/throttling.py @@ -0,0 +1,9 @@ +from rest_framework.throttling import AnonRateThrottle, UserRateThrottle + + +class AnonBurstRateThrottle(AnonRateThrottle): + scope = 'anon_burst' + + +class UserBurstRateThrottle(UserRateThrottle): + scope = 'user_burst' diff --git a/src/apps/api/views/competitions.py b/src/apps/api/views/competitions.py index a8e4c1b49..797c83f4e 100644 --- a/src/apps/api/views/competitions.py +++ b/src/apps/api/views/competitions.py @@ -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 @@ -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): @@ -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. diff --git a/src/settings/base.py b/src/settings/base.py index 3f39a2448..e55af362e 100644 --- a/src/settings/base.py +++ b/src/settings/base.py @@ -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': '20/min', + 'user_burst': '60/min', + 'competitions_public': '300/day', + }, 'DATETIME_INPUT_FORMATS': ( 'iso-8601', '%B %d, %Y', From f2eec13e1c95471ba4524a4747b506cfb46364e7 Mon Sep 17 00:00:00 2001 From: Ihsan Ullah Date: Mon, 3 Aug 2026 23:48:08 +0500 Subject: [PATCH 2/2] burst limits increased for circle ci tests --- src/settings/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/settings/base.py b/src/settings/base.py index e55af362e..3d1fe15f6 100644 --- a/src/settings/base.py +++ b/src/settings/base.py @@ -321,8 +321,8 @@ 'DEFAULT_THROTTLE_RATES': { 'anon': '100/day', 'user': '1000/day', - 'anon_burst': '20/min', - 'user_burst': '60/min', + 'anon_burst': '60/min', + 'user_burst': '300/min', 'competitions_public': '300/day', }, 'DATETIME_INPUT_FORMATS': (