Skip to content

Commit c33592e

Browse files
committed
Add throttling for Django REST Framework
We add two limits to the number of requests from authenticated users and non-authenticated users to prevent the APIs from being overloaded.
1 parent e079207 commit c33592e

File tree

7 files changed

+93
-0
lines changed

7 files changed

+93
-0
lines changed

promgen/admin.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,8 @@ def has_add_permission(self, request, obj=None):
172172

173173
def has_change_permission(self, request, obj=None):
174174
return False
175+
176+
177+
@admin.register(models.SiteConfiguration)
178+
class SiteConfigurationAdmin(admin.ModelAdmin):
179+
list_display = ("key", "value")
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Generated by Django 4.2.11 on 2025-07-10 01:21
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('promgen', '0027_alter_farm_owner_alter_project_owner_and_more'),
10+
]
11+
12+
operations = [
13+
migrations.CreateModel(
14+
name='SiteConfiguration',
15+
fields=[
16+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
17+
('key', models.CharField(max_length=128, unique=True)),
18+
('value', models.JSONField(default=dict)),
19+
],
20+
),
21+
]

promgen/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,3 +617,11 @@ class Meta:
617617
ordering = ["shard", "host"]
618618
unique_together = (("host", "port"),)
619619
verbose_name_plural = "prometheis"
620+
621+
622+
class SiteConfiguration(models.Model):
623+
key = models.CharField(max_length=128, unique=True)
624+
value = models.JSONField(default=dict)
625+
626+
def __str__(self):
627+
return f"{self.key}={self.value}"

promgen/settings.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,14 @@
197197
"DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",),
198198
"DEFAULT_SCHEMA_CLASS": "promgen.schemas.CustomSchema",
199199
"EXCEPTION_HANDLER": "promgen.middleware.custom_exception_handler",
200+
"DEFAULT_THROTTLE_CLASSES": [
201+
"promgen.util.UserRateThrottle",
202+
],
203+
"DEFAULT_THROTTLE_RATES": {
204+
# Limits the rate of API calls that may be made by a given user.
205+
# The user id will be used as a unique cache key.
206+
"user": "1000/day",
207+
},
200208
}
201209

202210
# If CELERY_BROKER_URL is set in our environment, then we configure celery as

promgen/signals.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,3 +333,12 @@ def add_default_project_subscription(instance, created, **kwargs):
333333
value=instance.owner.username,
334334
defaults={"owner": instance.owner},
335335
)
336+
337+
338+
@receiver(post_save, sender=models.SiteConfiguration)
339+
@skip_raw
340+
def clear_cache(*, sender, instance, **kwargs):
341+
# We need to clear our cache when we change our configuration
342+
# so that we can pick up the new settings
343+
if instance.key == "THROTTLE_RATES":
344+
cache.clear()

promgen/tests/test_rest.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# These sources are released under the terms of the MIT license: see LICENSE
33

44

5+
import django.core.cache
56
from django.contrib.auth.models import User, Permission
67
from django.test import override_settings
78
from django.urls import reverse
@@ -13,6 +14,8 @@
1314
class RestAPITest(tests.PromgenTest):
1415
def setUp(self):
1516
super().setUp()
17+
# Clear the cache before each test to reset throttling
18+
django.core.cache.cache.clear()
1619

1720
@override_settings(PROMGEN=tests.SETTINGS)
1821
@override_settings(CELERY_TASK_ALWAYS_EAGER=True)
@@ -2299,3 +2302,31 @@ def test_rest_shard(self):
22992302
)
23002303
self.assertEqual(response.status_code, 200)
23012304
self.assertEqual(response.json(), expected)
2305+
2306+
@override_settings(PROMGEN=tests.SETTINGS)
2307+
def test_throttling(self):
2308+
# Check throttling for authenticated users
2309+
token = Token.objects.filter(user__username="demo").first().key
2310+
for _ in range(1000):
2311+
response = self.client.get(
2312+
reverse("api-v2:service-list"), HTTP_AUTHORIZATION=f"Token {token}"
2313+
)
2314+
self.assertEqual(response.status_code, 200)
2315+
response = self.client.get(
2316+
reverse("api-v2:service-list"), HTTP_AUTHORIZATION=f"Token {token}"
2317+
)
2318+
self.assertEqual(response.status_code, 429)
2319+
2320+
# Check changing rate
2321+
models.SiteConfiguration.objects.get_or_create(
2322+
key="THROTTLE_RATES", value={"user": "3/day"}
2323+
)
2324+
for _ in range(3):
2325+
response = self.client.get(
2326+
reverse("api-v2:service-list"), HTTP_AUTHORIZATION=f"Token {token}"
2327+
)
2328+
self.assertEqual(response.status_code, 200)
2329+
response = self.client.get(
2330+
reverse("api-v2:service-list"), HTTP_AUTHORIZATION=f"Token {token}"
2331+
)
2332+
self.assertEqual(response.status_code, 429)

promgen/util.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
from django.conf import settings
99
from django.db.models import F
1010
from django.http import HttpResponse
11+
from rest_framework import throttling
12+
13+
from promgen import models
1114

1215
# Wrappers around request api to ensure we always attach our user agent
1316
# https://github.com/requests/requests/blob/master/requests/api.py
@@ -143,3 +146,11 @@ def proxy_error(response: requests.Response) -> HttpResponse:
143146
get.__doc__ = requests.get.__doc__
144147
post.__doc__ = requests.post.__doc__
145148
delete.__doc__ = requests.delete.__doc__
149+
150+
151+
class UserRateThrottle(throttling.UserRateThrottle):
152+
def get_rate(self):
153+
rate = models.SiteConfiguration.objects.filter(key="THROTTLE_RATES").first()
154+
if rate and rate.value["user"]:
155+
return rate.value["user"]
156+
return super().get_rate()

0 commit comments

Comments
 (0)