Skip to content

Commit 6823195

Browse files
committed
feat(api): Throttle dyndns API based on all domains given
1 parent e0fc9d0 commit 6823195

File tree

3 files changed

+64
-37
lines changed

3 files changed

+64
-37
lines changed

api/desecapi/tests/test_throttling_scoped.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ class ThrottlingTestCase(TestCase):
4242
def setUp(self):
4343
super().setUp()
4444
self.factory = APIRequestFactory()
45+
cache.clear()
4546

4647
def _test_requests_are_throttled(self, rates, counts, buckets=None):
4748
def do_test():
@@ -65,16 +66,16 @@ def do_test():
6566
max_wait - 1 <= float(response["Retry-After"]) <= max_wait
6667
)
6768

68-
cache.clear()
6969
request = self.factory.get("/")
7070
with override_settings(
7171
REST_FRAMEWORK={"DEFAULT_THROTTLE_RATES": {MockView.throttle_scope: rates}}
7272
):
73-
do_test()
7473
if buckets is not None:
7574
for bucket in buckets:
7675
with override_bucket(bucket):
7776
do_test()
77+
else:
78+
do_test()
7879

7980
def test_requests_are_throttled_4sec(self):
8081
self._test_requests_are_throttled(["4/sec"], [(0, 4, 1), (1, 4, 1)])
@@ -85,8 +86,10 @@ def test_requests_are_throttled_4min(self):
8586
def test_requests_are_throttled_2per2min(self):
8687
self._test_requests_are_throttled(["2/2min"], [(0, 2, 120)])
8788

88-
def test_requests_are_throttled_multiple(self):
89+
def test_requests_are_throttled_multiple_5sec_4day(self):
8990
self._test_requests_are_throttled(["5/s", "4/day"], [(0, 4, 86400)])
91+
92+
def test_requests_are_throttled_multiple_4sec_5day(self):
9093
self._test_requests_are_throttled(["4/s", "5/day"], [(0, 4, 1)])
9194

9295
def test_requests_are_throttled_multiple_cascade(self):
@@ -98,3 +101,18 @@ def test_requests_are_throttled_multiple_cascade_with_buckets(self):
98101
self._test_requests_are_throttled(
99102
["4/s", "6/day"], [(0, 4, 1), (1, 2, 86400)], buckets=["foo", "bar"]
100103
)
104+
105+
def test_requests_are_throttled_multiple_cascase_with_parallel_buckets(self):
106+
# foo is thottled after 4 requests
107+
self._test_requests_are_throttled(
108+
["4/s", "6/day"], [(0, 4, 1)], buckets=["foo"]
109+
)
110+
# a request using foo and bar is throttled (as foo is already throttled)
111+
# but after foo is unthottled, 2 requests are allowed in the second second until foo is throttled again
112+
self._test_requests_are_throttled(
113+
["4/s", "6/day"], [(0, 0, 1), (1, 2, 86400)], buckets=[{"foo", "bar"}]
114+
)
115+
# bar still can take 2 more requests in the second second and 2 more in the third second
116+
self._test_requests_are_throttled(
117+
["4/s", "6/day"], [(1, 2, 1), (1, 2, 86400)], buckets=["bar"]
118+
)

api/desecapi/throttling.py

Lines changed: 42 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -39,45 +39,54 @@ def allow_request(self, request, view):
3939
if self.rate is None:
4040
return True
4141

42-
# Amend scope with optional bucket
43-
bucket = getattr(view, self.scope_attr + "_bucket", None)
44-
if bucket is not None:
45-
self.scope += ":" + sha1(bucket.encode()).hexdigest()
42+
buckets = getattr(view, self.scope_attr + "_bucket", None)
43+
if not isinstance(buckets, set):
44+
buckets = {buckets}
4645

4746
self.now = self.timer()
4847
self.num_requests, self.duration = zip(*self.parse_rate(self.rate))
49-
self.key = self.get_cache_key(request, view)
50-
self.history = {key: [] for key in self.key}
51-
self.history.update(self.cache.get_many(self.key))
52-
53-
for num_requests, duration, key in zip(
54-
self.num_requests, self.duration, self.key
55-
):
56-
history = self.history[key]
57-
# Drop any requests from the history which have now passed the
58-
# throttle duration
59-
while history and history[-1] <= self.now - duration:
60-
history.pop()
61-
if len(history) >= num_requests:
62-
# Prepare variables used by the Throttle's wait() method that gets called by APIView.check_throttles()
63-
self.num_requests, self.duration, self.key, self.history = (
64-
num_requests,
65-
duration,
66-
key,
67-
history,
68-
)
69-
response = self.throttle_failure()
70-
metrics.get("desecapi_throttle_failure").labels(
71-
request.method, scope, request.user.pk, bucket
72-
).inc()
73-
return response
74-
self.history[key] = history
48+
self.histories = {}
49+
for bucket in buckets:
50+
# Amend scope with optional bucket
51+
if bucket is not None:
52+
self.scope = scope + ":" + sha1(bucket.encode()).hexdigest()
53+
else:
54+
self.scope = scope
55+
56+
self.key = self.get_cache_key(request, view)
57+
bucket_history = {key: [] for key in self.key}
58+
bucket_history.update(self.cache.get_many(self.key))
59+
60+
for num_requests, duration, key in zip(
61+
self.num_requests, self.duration, self.key
62+
):
63+
history = bucket_history[key]
64+
# Drop any requests from the history which have now passed the
65+
# throttle duration
66+
while history and history[-1] <= self.now - duration:
67+
history.pop()
68+
if len(history) >= num_requests:
69+
# Prepare variables used by the Throttle's wait() method that gets called by APIView.check_throttles()
70+
self.num_requests, self.duration, self.key, self.history = (
71+
num_requests,
72+
duration,
73+
key,
74+
history,
75+
)
76+
response = self.throttle_failure()
77+
metrics.get("desecapi_throttle_failure").labels(
78+
request.method, scope, request.user.pk, bucket
79+
).inc()
80+
return response
81+
bucket_history[key] = history
82+
self.histories[bucket] = bucket_history
7583
return self.throttle_success()
7684

7785
def throttle_success(self):
78-
for key in self.history:
79-
self.history[key].insert(0, self.now)
80-
self.cache.set_many(self.history, max(self.duration))
86+
for bucket_history in self.histories.values():
87+
for history in bucket_history.values():
88+
history.insert(0, self.now)
89+
self.cache.set_many(bucket_history, max(self.duration))
8190
return True
8291

8392
# Override the static attribute of the parent class so that we can dynamically apply override settings for testing

api/desecapi/views/dyndns.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ class DynDNS12UpdateView(generics.GenericAPIView):
7373

7474
@property
7575
def throttle_scope_bucket(self):
76-
return ",".join(sorted([d.name for d in self.domains]))
76+
return {d.name for d in self.domains}
7777

7878
def _find_action(
7979
self, param_keys, separator, use_remote_ip_fallback=False

0 commit comments

Comments
 (0)