Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Fix a long-standing bug which meant that rate limiting was not restrictive enough in some cases. #13018

Merged
merged 7 commits into from
Jun 15, 2022
Merged
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
1 change: 1 addition & 0 deletions changelog.d/13018.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix a long-standing bug which meant that rate limiting was not restrictive enough in some cases.
5 changes: 4 additions & 1 deletion synapse/api/ratelimiting.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ async def can_do_action(
performed_count = action_count - time_delta * rate_hz
if performed_count < 0:
performed_count = 0

# Reset the start time and forgive all actions
action_count = 0
time_start = time_now_s

# This check would be easier read as performed_count + n_actions > burst_count,
Expand All @@ -140,7 +143,7 @@ async def can_do_action(
else:
# We haven't reached our limit yet
allowed = True
action_count = performed_count + n_actions
action_count = action_count + n_actions

if update:
self.actions[key] = (action_count, time_start, rate_hz)
Expand Down
51 changes: 40 additions & 11 deletions tests/api/test_ratelimiting.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,15 +246,16 @@ def test_multiple_actions(self):
self.assertTrue(allowed)
self.assertEqual(10.0, time_allowed)

# Test that, after doing these 3 actions, we can't do any more action without
# Test that, after doing these 3 actions, we can't do any more actions without
# waiting.
allowed, time_allowed = self.get_success_or_raise(
limiter.can_do_action(None, key="test_id", n_actions=1, _time_now_s=0)
)
self.assertFalse(allowed)
self.assertEqual(10.0, time_allowed)

# Test that after waiting we can do only 1 action.
# Test that after waiting we would be able to do only 1 action.
# Note that we don't actually do it (update=False) here.
allowed, time_allowed = self.get_success_or_raise(
limiter.can_do_action(
None,
Expand All @@ -265,23 +266,51 @@ def test_multiple_actions(self):
)
)
self.assertTrue(allowed)
# The time allowed is the current time because we could still repeat the action
# once.
self.assertEqual(10.0, time_allowed)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like arguing with existing tests (it's like the good ol' 'compiler bug!' excuse) but I do think this test is wrong. Please check carefully and let me know if you dis/agree.

# We would be able to do the 5th action at t=20.
self.assertEqual(20.0, time_allowed)

# Attempt (but fail) to perform TWO actions at t=10.
# Those would be the 4th and 5th actions.
allowed, time_allowed = self.get_success_or_raise(
limiter.can_do_action(None, key="test_id", n_actions=2, _time_now_s=10)
)
self.assertFalse(allowed)
# The time allowed doesn't change despite allowed being False because, while we
# don't allow 2 actions, we could still do 1.
# The returned time allowed for the next action is now even though we weren't
# allowed to perform the action because whilst we don't allow 2 actions,
# we could still do 1.
self.assertEqual(10.0, time_allowed)

# Test that after waiting a bit more we can do 2 actions.
# Test that after waiting until t=20, we can do perform 2 actions.
# These are the 4th and 5th actions.
allowed, time_allowed = self.get_success_or_raise(
limiter.can_do_action(None, key="test_id", n_actions=2, _time_now_s=20)
)
self.assertTrue(allowed)
# The time allowed is the current time because we could still repeat the action
# once.
self.assertEqual(20.0, time_allowed)
# We would be able to do the 6th action at t=30.
self.assertEqual(30.0, time_allowed)

def test_rate_limit_burst_only_given_once(self) -> None:
"""
Regression test against a bug that meant that you could build up
extra tokens by timing requests.
"""
limiter = Ratelimiter(
store=self.hs.get_datastores().main, clock=None, rate_hz=0.1, burst_count=3
)

def consume_at(time: float) -> bool:
success, _ = self.get_success_or_raise(
limiter.can_do_action(requester=None, key="a", _time_now_s=time)
)
return success

# Use all our 3 burst tokens
self.assertTrue(consume_at(0.0))
self.assertTrue(consume_at(0.1))
self.assertTrue(consume_at(0.2))

# Wait to recover 1 token (10 seconds at 0.1 Hz).
self.assertTrue(consume_at(10.1))

# Check that we get rate limited after using that token.
self.assertFalse(consume_at(11.1))