Skip to content

Commit 2799fcd

Browse files
committed
Add ratelimit callbacks to module API to allow dynamic ratelimiting
Adds new callback `get_ratelimit_override_for_user` which is invoked for a small subset of limiter types.
1 parent 2436512 commit 2799fcd

10 files changed

Lines changed: 195 additions & 2 deletions

File tree

changelog.d/18458.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add a new module API callback that allows overriding of per user ratelimits.

docs/SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
- [Background update controller callbacks](modules/background_update_controller_callbacks.md)
5050
- [Account data callbacks](modules/account_data_callbacks.md)
5151
- [Add extra fields to client events unsigned section callbacks](modules/add_extra_fields_to_client_events_unsigned.md)
52+
- [Ratelimit callbacks](modules/ratelimit_callbacks.md)
5253
- [Porting a legacy module to the new interface](modules/porting_legacy_module.md)
5354
- [Workers](workers.md)
5455
- [Using `synctl` with Workers](synctl_workers.md)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Ratelimit callbacks
2+
3+
Ratelimit callbacks allow module developers to override ratelimit settings dynamically whilst
4+
Synapse is running. Ratelimit callbacks can be registered using the module API's
5+
`register_ratelimit_callbacks` method.
6+
7+
The available ratelimit callbacks are:
8+
9+
### `get_ratelimit_override_for_user`
10+
11+
_First introduced in Synapse v1.X.X_
12+
13+
```python
14+
async def get_ratelimit_override_for_user(user: str, limiter_name: str) -> Optional[RatelimitOverride]
15+
```
16+
17+
Called when constructing a ratelimiter of a particular type for a user. The module can
18+
return a `messages_per_second` and `burst_count` to be used, or `None` if no
19+
the default settings are adequate. The user is represented by their Matrix user ID
20+
(e.g. `@alice:example.com`). The limiter name is usually taken from the `RatelimitSettings` key
21+
value.
22+
23+
The limiters that are currently supported are:
24+
25+
- `rc_invites.per_room`
26+
- `rc_invites.per_user`
27+
- `rc_invites.per_issuer`
28+
29+
If multiple modules implement this callback, they will be considered in order. If a
30+
callback returns `None`, Synapse falls through to the next one. The value of the first
31+
callback that does not return `None` will be used. If this happens, Synapse will not call
32+
any of the subsequent implementations of this callback. If no module returns a non-`None` value
33+
then the default settings will be used.

synapse/api/ratelimiting.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,20 @@
2020
#
2121
#
2222

23-
from typing import Dict, Hashable, Optional, Tuple
23+
from typing import TYPE_CHECKING, Dict, Hashable, Optional, Tuple
2424

2525
from synapse.api.errors import LimitExceededError
2626
from synapse.config.ratelimiting import RatelimitSettings
2727
from synapse.storage.databases.main import DataStore
2828
from synapse.types import Requester
2929
from synapse.util import Clock
3030

31+
if TYPE_CHECKING:
32+
# To avoid circular imports:
33+
from synapse.module_api.callbacks.ratelimit_callbacks import (
34+
RatelimitModuleApiCallbacks,
35+
)
36+
3137

3238
class Ratelimiter:
3339
"""
@@ -72,12 +78,14 @@ def __init__(
7278
store: DataStore,
7379
clock: Clock,
7480
cfg: RatelimitSettings,
81+
ratelimit_callbacks: Optional["RatelimitModuleApiCallbacks"] = None,
7582
):
7683
self.clock = clock
7784
self.rate_hz = cfg.per_second
7885
self.burst_count = cfg.burst_count
7986
self.store = store
8087
self._limiter_name = cfg.key
88+
self._ratelimit_callbacks = ratelimit_callbacks
8189

8290
# A dictionary representing the token buckets tracked by this rate
8391
# limiter. Each entry maps a key of arbitrary type to a tuple representing:
@@ -165,6 +173,20 @@ async def can_do_action(
165173
if override and not override.messages_per_second:
166174
return True, -1.0
167175

176+
if requester and self._ratelimit_callbacks:
177+
# Check if the user has a custom rate limit for this specific limiter
178+
# as returned by the module API.
179+
module_override = (
180+
await self._ratelimit_callbacks.get_ratelimit_override_for_user(
181+
requester.user.to_string(),
182+
self._limiter_name,
183+
)
184+
)
185+
186+
if module_override:
187+
rate_hz = module_override.messages_per_second
188+
burst_count = module_override.burst_count
189+
168190
# Override default values if set
169191
time_now_s = _time_now_s if _time_now_s is not None else self.clock.time()
170192
rate_hz = rate_hz if rate_hz is not None else self.rate_hz

synapse/handlers/room_member.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ def __init__(self, hs: "HomeServer"):
158158
store=self.store,
159159
clock=self.clock,
160160
cfg=hs.config.ratelimiting.rc_invites_per_room,
161+
ratelimit_callbacks=hs.get_module_api_callbacks().ratelimit,
161162
)
162163

163164
# Ratelimiter for invites, keyed by recipient (across all rooms, all
@@ -166,6 +167,7 @@ def __init__(self, hs: "HomeServer"):
166167
store=self.store,
167168
clock=self.clock,
168169
cfg=hs.config.ratelimiting.rc_invites_per_user,
170+
ratelimit_callbacks=hs.get_module_api_callbacks().ratelimit,
169171
)
170172

171173
# Ratelimiter for invites, keyed by issuer (across all rooms, all
@@ -174,6 +176,7 @@ def __init__(self, hs: "HomeServer"):
174176
store=self.store,
175177
clock=self.clock,
176178
cfg=hs.config.ratelimiting.rc_invites_per_issuer,
179+
ratelimit_callbacks=hs.get_module_api_callbacks().ratelimit,
177180
)
178181

179182
self._third_party_invite_limiter = Ratelimiter(

synapse/module_api/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@
9090
ON_USER_LOGIN_CALLBACK,
9191
ON_USER_REGISTRATION_CALLBACK,
9292
)
93+
from synapse.module_api.callbacks.ratelimit_callbacks import (
94+
GET_RATELIMIT_OVERRIDE_FOR_USER_CALLBACK,
95+
)
9396
from synapse.module_api.callbacks.spamchecker_callbacks import (
9497
CHECK_EVENT_FOR_SPAM_CALLBACK,
9598
CHECK_LOGIN_FOR_SPAM_CALLBACK,
@@ -360,6 +363,20 @@ def register_account_validity_callbacks(
360363
on_legacy_admin_request=on_legacy_admin_request,
361364
)
362365

366+
def register_ratelimit_callbacks(
367+
self,
368+
*,
369+
get_ratelimit_override_for_user: Optional[
370+
GET_RATELIMIT_OVERRIDE_FOR_USER_CALLBACK
371+
] = None,
372+
) -> None:
373+
"""Registers callbacks for ratelimit capabilities.
374+
Added in Synapse v1.x.x.
375+
"""
376+
return self._callbacks.ratelimit.register_callbacks(
377+
get_ratelimit_override_for_user=get_ratelimit_override_for_user,
378+
)
379+
363380
def register_third_party_rules_callbacks(
364381
self,
365382
*,

synapse/module_api/callbacks/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@
2727
from synapse.module_api.callbacks.account_validity_callbacks import (
2828
AccountValidityModuleApiCallbacks,
2929
)
30+
from synapse.module_api.callbacks.ratelimit_callbacks import (
31+
RatelimitModuleApiCallbacks,
32+
)
3033
from synapse.module_api.callbacks.spamchecker_callbacks import (
3134
SpamCheckerModuleApiCallbacks,
3235
)
@@ -38,5 +41,6 @@
3841
class ModuleApiCallbacks:
3942
def __init__(self, hs: "HomeServer") -> None:
4043
self.account_validity = AccountValidityModuleApiCallbacks()
44+
self.ratelimit = RatelimitModuleApiCallbacks(hs)
4145
self.spam_checker = SpamCheckerModuleApiCallbacks(hs)
4246
self.third_party_event_rules = ThirdPartyEventRulesModuleApiCallbacks(hs)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
#
2+
# This file is licensed under the Affero General Public License (AGPL) version 3.
3+
#
4+
# Copyright (C) 2025 New Vector, Ltd
5+
#
6+
# This program is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU Affero General Public License as
8+
# published by the Free Software Foundation, either version 3 of the
9+
# License, or (at your option) any later version.
10+
#
11+
# See the GNU Affero General Public License for more details:
12+
# <https://www.gnu.org/licenses/agpl-3.0.html>.
13+
#
14+
15+
import logging
16+
from typing import TYPE_CHECKING, Awaitable, Callable, List, Optional
17+
18+
from synapse.storage.databases.main.room import RatelimitOverride
19+
from synapse.util.async_helpers import delay_cancellation
20+
from synapse.util.metrics import Measure
21+
22+
if TYPE_CHECKING:
23+
from synapse.server import HomeServer
24+
25+
logger = logging.getLogger(__name__)
26+
27+
GET_RATELIMIT_OVERRIDE_FOR_USER_CALLBACK = Callable[
28+
[str, str], Awaitable[Optional[RatelimitOverride]]
29+
]
30+
31+
32+
class RatelimitModuleApiCallbacks:
33+
def __init__(self, hs: "HomeServer") -> None:
34+
self.clock = hs.get_clock()
35+
self._get_ratelimit_override_for_user_callbacks: List[
36+
GET_RATELIMIT_OVERRIDE_FOR_USER_CALLBACK
37+
] = []
38+
39+
def register_callbacks(
40+
self,
41+
get_ratelimit_override_for_user: Optional[
42+
GET_RATELIMIT_OVERRIDE_FOR_USER_CALLBACK
43+
] = None,
44+
) -> None:
45+
"""Register callbacks from module for each hook."""
46+
if get_ratelimit_override_for_user is not None:
47+
self._get_ratelimit_override_for_user_callbacks.append(
48+
get_ratelimit_override_for_user
49+
)
50+
51+
async def get_ratelimit_override_for_user(
52+
self, user_id: str, limiter_name: str
53+
) -> Optional[RatelimitOverride]:
54+
for callback in self._get_ratelimit_override_for_user_callbacks:
55+
with Measure(self.clock, f"{callback.__module__}.{callback.__qualname__}"):
56+
res: Optional[RatelimitOverride] = await delay_cancellation(
57+
callback(user_id, limiter_name)
58+
)
59+
if res:
60+
return res
61+
62+
return None

synapse/storage/databases/main/room.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@
7777

7878
@attr.s(slots=True, frozen=True, auto_attribs=True)
7979
class RatelimitOverride:
80-
messages_per_second: int
80+
messages_per_second: float
8181
burst_count: int
8282

8383

tests/api/test_ratelimiting.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
from typing import Optional
2+
13
from synapse.api.ratelimiting import LimitExceededError, Ratelimiter
24
from synapse.appservice import ApplicationService
35
from synapse.config.ratelimiting import RatelimitSettings
6+
from synapse.module_api.callbacks.ratelimit_callbacks import RatelimitModuleApiCallbacks
7+
from synapse.storage.databases.main.room import RatelimitOverride
48
from synapse.types import create_requester
59

610
from tests import unittest
@@ -440,3 +444,49 @@ def test_record_action_which_overfills_bucket(self) -> None:
440444
limiter.can_do_action(requester=None, key="a", _time_now_s=20.0)
441445
)
442446
self.assertTrue(success)
447+
448+
def test_get_ratelimit_override_for_user_callback(self) -> None:
449+
test_user_id = "@user:test"
450+
test_limiter_name = "name"
451+
callbacks = RatelimitModuleApiCallbacks(self.hs)
452+
requester = create_requester(test_user_id)
453+
limiter = Ratelimiter(
454+
store=self.hs.get_datastores().main,
455+
clock=self.clock,
456+
cfg=RatelimitSettings(
457+
test_limiter_name,
458+
per_second=0.1,
459+
burst_count=3,
460+
),
461+
ratelimit_callbacks=callbacks,
462+
)
463+
464+
# Observe four actions, exceeding the burst_count.
465+
limiter.record_action(requester=requester, n_actions=4, _time_now_s=0.0)
466+
467+
# We should be prevented from taking a new action now.
468+
success, _ = self.get_success_or_raise(
469+
limiter.can_do_action(requester=requester, _time_now_s=0.0)
470+
)
471+
self.assertFalse(success)
472+
473+
# Now register a callback that overrides the ratelimit for this user
474+
# and limiter name.
475+
async def get_ratelimit_override_for_user(
476+
user_id: str, limiter_name: str
477+
) -> Optional[RatelimitOverride]:
478+
if user_id == test_user_id:
479+
return RatelimitOverride(
480+
messages_per_second=0.1,
481+
burst_count=10,
482+
)
483+
return None
484+
485+
callbacks.register_callbacks(
486+
get_ratelimit_override_for_user=get_ratelimit_override_for_user
487+
)
488+
489+
success, _ = self.get_success_or_raise(
490+
limiter.can_do_action(requester=requester, _time_now_s=0.0)
491+
)
492+
self.assertTrue(success)

0 commit comments

Comments
 (0)