diff --git a/changelog.d/18458.feature b/changelog.d/18458.feature
new file mode 100644
index 0000000000..92f11a8a2c
--- /dev/null
+++ b/changelog.d/18458.feature
@@ -0,0 +1 @@
+Add a new module API callback that allows overriding of per user ratelimits.
\ No newline at end of file
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
index abb1d5603c..40d70a4485 100644
--- a/docs/SUMMARY.md
+++ b/docs/SUMMARY.md
@@ -49,7 +49,8 @@
- [Background update controller callbacks](modules/background_update_controller_callbacks.md)
- [Account data callbacks](modules/account_data_callbacks.md)
- [Add extra fields to client events unsigned section callbacks](modules/add_extra_fields_to_client_events_unsigned.md)
- - [Media repository](modules/media_repository_callbacks.md)
+ - [Media repository callbacks](modules/media_repository_callbacks.md)
+ - [Ratelimit callbacks](modules/ratelimit_callbacks.md)
- [Porting a legacy module to the new interface](modules/porting_legacy_module.md)
- [Workers](workers.md)
- [Using `synctl` with Workers](synctl_workers.md)
diff --git a/docs/modules/ratelimit_callbacks.md b/docs/modules/ratelimit_callbacks.md
new file mode 100644
index 0000000000..bf923c045e
--- /dev/null
+++ b/docs/modules/ratelimit_callbacks.md
@@ -0,0 +1,33 @@
+# Ratelimit callbacks
+
+Ratelimit callbacks allow module developers to override ratelimit settings dynamically whilst
+Synapse is running. Ratelimit callbacks can be registered using the module API's
+`register_ratelimit_callbacks` method.
+
+The available ratelimit callbacks are:
+
+### `get_ratelimit_override_for_user`
+
+_First introduced in Synapse v1.132.0_
+
+```python
+async def get_ratelimit_override_for_user(user: str, limiter_name: str) -> Optional[RatelimitOverride]
+```
+
+Called when constructing a ratelimiter of a particular type for a user. The module can
+return a `messages_per_second` and `burst_count` to be used, or `None` if
+the default settings are adequate. The user is represented by their Matrix user ID
+(e.g. `@alice:example.com`). The limiter name is usually taken from the `RatelimitSettings` key
+value.
+
+The limiters that are currently supported are:
+
+- `rc_invites.per_room`
+- `rc_invites.per_user`
+- `rc_invites.per_issuer`
+
+If multiple modules implement this callback, they will be considered in order. If a
+callback returns `None`, Synapse falls through to the next one. The value of the first
+callback that does not return `None` will be used. If this happens, Synapse will not call
+any of the subsequent implementations of this callback. If no module returns a non-`None` value
+then the default settings will be used.
diff --git a/synapse/api/ratelimiting.py b/synapse/api/ratelimiting.py
index 8665b3b765..38e5bdaa75 100644
--- a/synapse/api/ratelimiting.py
+++ b/synapse/api/ratelimiting.py
@@ -20,7 +20,7 @@
#
#
-from typing import Dict, Hashable, Optional, Tuple
+from typing import TYPE_CHECKING, Dict, Hashable, Optional, Tuple
from synapse.api.errors import LimitExceededError
from synapse.config.ratelimiting import RatelimitSettings
@@ -28,6 +28,12 @@ from synapse.storage.databases.main import DataStore
from synapse.types import Requester
from synapse.util import Clock
+if TYPE_CHECKING:
+ # To avoid circular imports:
+ from synapse.module_api.callbacks.ratelimit_callbacks import (
+ RatelimitModuleApiCallbacks,
+ )
+
class Ratelimiter:
"""
@@ -72,12 +78,14 @@ class Ratelimiter:
store: DataStore,
clock: Clock,
cfg: RatelimitSettings,
+ ratelimit_callbacks: Optional["RatelimitModuleApiCallbacks"] = None,
):
self.clock = clock
self.rate_hz = cfg.per_second
self.burst_count = cfg.burst_count
self.store = store
self._limiter_name = cfg.key
+ self._ratelimit_callbacks = ratelimit_callbacks
# A dictionary representing the token buckets tracked by this rate
# limiter. Each entry maps a key of arbitrary type to a tuple representing:
@@ -165,6 +173,20 @@ class Ratelimiter:
if override and not override.messages_per_second:
return True, -1.0
+ if requester and self._ratelimit_callbacks:
+ # Check if the user has a custom rate limit for this specific limiter
+ # as returned by the module API.
+ module_override = (
+ await self._ratelimit_callbacks.get_ratelimit_override_for_user(
+ requester.user.to_string(),
+ self._limiter_name,
+ )
+ )
+
+ if module_override:
+ rate_hz = module_override.messages_per_second
+ burst_count = module_override.burst_count
+
# Override default values if set
time_now_s = _time_now_s if _time_now_s is not None else self.clock.time()
rate_hz = rate_hz if rate_hz is not None else self.rate_hz
diff --git a/synapse/handlers/room_member.py b/synapse/handlers/room_member.py
index 70cbbc352b..b0e750c9c7 100644
--- a/synapse/handlers/room_member.py
+++ b/synapse/handlers/room_member.py
@@ -158,6 +158,7 @@ class RoomMemberHandler(metaclass=abc.ABCMeta):
store=self.store,
clock=self.clock,
cfg=hs.config.ratelimiting.rc_invites_per_room,
+ ratelimit_callbacks=hs.get_module_api_callbacks().ratelimit,
)
# Ratelimiter for invites, keyed by recipient (across all rooms, all
@@ -166,6 +167,7 @@ class RoomMemberHandler(metaclass=abc.ABCMeta):
store=self.store,
clock=self.clock,
cfg=hs.config.ratelimiting.rc_invites_per_user,
+ ratelimit_callbacks=hs.get_module_api_callbacks().ratelimit,
)
# Ratelimiter for invites, keyed by issuer (across all rooms, all
@@ -174,6 +176,7 @@ class RoomMemberHandler(metaclass=abc.ABCMeta):
store=self.store,
clock=self.clock,
cfg=hs.config.ratelimiting.rc_invites_per_issuer,
+ ratelimit_callbacks=hs.get_module_api_callbacks().ratelimit,
)
self._third_party_invite_limiter = Ratelimiter(
diff --git a/synapse/module_api/__init__.py b/synapse/module_api/__init__.py
index 3148957229..e36503a526 100644
--- a/synapse/module_api/__init__.py
+++ b/synapse/module_api/__init__.py
@@ -94,6 +94,9 @@ from synapse.module_api.callbacks.media_repository_callbacks import (
GET_MEDIA_CONFIG_FOR_USER_CALLBACK,
IS_USER_ALLOWED_TO_UPLOAD_MEDIA_OF_SIZE_CALLBACK,
)
+from synapse.module_api.callbacks.ratelimit_callbacks import (
+ GET_RATELIMIT_OVERRIDE_FOR_USER_CALLBACK,
+)
from synapse.module_api.callbacks.spamchecker_callbacks import (
CHECK_EVENT_FOR_SPAM_CALLBACK,
CHECK_LOGIN_FOR_SPAM_CALLBACK,
@@ -367,6 +370,20 @@ class ModuleApi:
on_legacy_admin_request=on_legacy_admin_request,
)
+ def register_ratelimit_callbacks(
+ self,
+ *,
+ get_ratelimit_override_for_user: Optional[
+ GET_RATELIMIT_OVERRIDE_FOR_USER_CALLBACK
+ ] = None,
+ ) -> None:
+ """Registers callbacks for ratelimit capabilities.
+ Added in Synapse v1.132.0.
+ """
+ return self._callbacks.ratelimit.register_callbacks(
+ get_ratelimit_override_for_user=get_ratelimit_override_for_user,
+ )
+
def register_media_repository_callbacks(
self,
*,
@@ -376,7 +393,7 @@ class ModuleApi:
] = None,
) -> None:
"""Registers callbacks for media repository capabilities.
- Added in Synapse v1.x.x.
+ Added in Synapse v1.132.0.
"""
return self._callbacks.media_repository.register_callbacks(
get_media_config_for_user=get_media_config_for_user,
diff --git a/synapse/module_api/callbacks/__init__.py b/synapse/module_api/callbacks/__init__.py
index a36c0fc7c6..16ef7a4b47 100644
--- a/synapse/module_api/callbacks/__init__.py
+++ b/synapse/module_api/callbacks/__init__.py
@@ -30,6 +30,9 @@ from synapse.module_api.callbacks.account_validity_callbacks import (
from synapse.module_api.callbacks.media_repository_callbacks import (
MediaRepositoryModuleApiCallbacks,
)
+from synapse.module_api.callbacks.ratelimit_callbacks import (
+ RatelimitModuleApiCallbacks,
+)
from synapse.module_api.callbacks.spamchecker_callbacks import (
SpamCheckerModuleApiCallbacks,
)
@@ -42,5 +45,6 @@ class ModuleApiCallbacks:
def __init__(self, hs: "HomeServer") -> None:
self.account_validity = AccountValidityModuleApiCallbacks()
self.media_repository = MediaRepositoryModuleApiCallbacks(hs)
+ self.ratelimit = RatelimitModuleApiCallbacks(hs)
self.spam_checker = SpamCheckerModuleApiCallbacks(hs)
self.third_party_event_rules = ThirdPartyEventRulesModuleApiCallbacks(hs)
diff --git a/synapse/module_api/callbacks/ratelimit_callbacks.py b/synapse/module_api/callbacks/ratelimit_callbacks.py
new file mode 100644
index 0000000000..ced721b407
--- /dev/null
+++ b/synapse/module_api/callbacks/ratelimit_callbacks.py
@@ -0,0 +1,62 @@
+#
+# This file is licensed under the Affero General Public License (AGPL) version 3.
+#
+# Copyright (C) 2025 New Vector, Ltd
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# See the GNU Affero General Public License for more details:
+# <https://www.gnu.org/licenses/agpl-3.0.html>.
+#
+
+import logging
+from typing import TYPE_CHECKING, Awaitable, Callable, List, Optional
+
+from synapse.storage.databases.main.room import RatelimitOverride
+from synapse.util.async_helpers import delay_cancellation
+from synapse.util.metrics import Measure
+
+if TYPE_CHECKING:
+ from synapse.server import HomeServer
+
+logger = logging.getLogger(__name__)
+
+GET_RATELIMIT_OVERRIDE_FOR_USER_CALLBACK = Callable[
+ [str, str], Awaitable[Optional[RatelimitOverride]]
+]
+
+
+class RatelimitModuleApiCallbacks:
+ def __init__(self, hs: "HomeServer") -> None:
+ self.clock = hs.get_clock()
+ self._get_ratelimit_override_for_user_callbacks: List[
+ GET_RATELIMIT_OVERRIDE_FOR_USER_CALLBACK
+ ] = []
+
+ def register_callbacks(
+ self,
+ get_ratelimit_override_for_user: Optional[
+ GET_RATELIMIT_OVERRIDE_FOR_USER_CALLBACK
+ ] = None,
+ ) -> None:
+ """Register callbacks from module for each hook."""
+ if get_ratelimit_override_for_user is not None:
+ self._get_ratelimit_override_for_user_callbacks.append(
+ get_ratelimit_override_for_user
+ )
+
+ async def get_ratelimit_override_for_user(
+ self, user_id: str, limiter_name: str
+ ) -> Optional[RatelimitOverride]:
+ for callback in self._get_ratelimit_override_for_user_callbacks:
+ with Measure(self.clock, f"{callback.__module__}.{callback.__qualname__}"):
+ res: Optional[RatelimitOverride] = await delay_cancellation(
+ callback(user_id, limiter_name)
+ )
+ if res:
+ return res
+
+ return None
diff --git a/synapse/storage/databases/main/room.py b/synapse/storage/databases/main/room.py
index 56217fccdf..46491cc0fe 100644
--- a/synapse/storage/databases/main/room.py
+++ b/synapse/storage/databases/main/room.py
@@ -77,7 +77,7 @@ logger = logging.getLogger(__name__)
@attr.s(slots=True, frozen=True, auto_attribs=True)
class RatelimitOverride:
- messages_per_second: int
+ messages_per_second: float
burst_count: int
diff --git a/tests/api/test_ratelimiting.py b/tests/api/test_ratelimiting.py
index 1a1cbde74e..5e73f5d5ec 100644
--- a/tests/api/test_ratelimiting.py
+++ b/tests/api/test_ratelimiting.py
@@ -1,6 +1,10 @@
+from typing import Optional
+
from synapse.api.ratelimiting import LimitExceededError, Ratelimiter
from synapse.appservice import ApplicationService
from synapse.config.ratelimiting import RatelimitSettings
+from synapse.module_api.callbacks.ratelimit_callbacks import RatelimitModuleApiCallbacks
+from synapse.storage.databases.main.room import RatelimitOverride
from synapse.types import create_requester
from tests import unittest
@@ -440,3 +444,49 @@ class TestRatelimiter(unittest.HomeserverTestCase):
limiter.can_do_action(requester=None, key="a", _time_now_s=20.0)
)
self.assertTrue(success)
+
+ def test_get_ratelimit_override_for_user_callback(self) -> None:
+ test_user_id = "@user:test"
+ test_limiter_name = "name"
+ callbacks = RatelimitModuleApiCallbacks(self.hs)
+ requester = create_requester(test_user_id)
+ limiter = Ratelimiter(
+ store=self.hs.get_datastores().main,
+ clock=self.clock,
+ cfg=RatelimitSettings(
+ test_limiter_name,
+ per_second=0.1,
+ burst_count=3,
+ ),
+ ratelimit_callbacks=callbacks,
+ )
+
+ # Observe four actions, exceeding the burst_count.
+ limiter.record_action(requester=requester, n_actions=4, _time_now_s=0.0)
+
+ # We should be prevented from taking a new action now.
+ success, _ = self.get_success_or_raise(
+ limiter.can_do_action(requester=requester, _time_now_s=0.0)
+ )
+ self.assertFalse(success)
+
+ # Now register a callback that overrides the ratelimit for this user
+ # and limiter name.
+ async def get_ratelimit_override_for_user(
+ user_id: str, limiter_name: str
+ ) -> Optional[RatelimitOverride]:
+ if user_id == test_user_id:
+ return RatelimitOverride(
+ messages_per_second=0.1,
+ burst_count=10,
+ )
+ return None
+
+ callbacks.register_callbacks(
+ get_ratelimit_override_for_user=get_ratelimit_override_for_user
+ )
+
+ success, _ = self.get_success_or_raise(
+ limiter.can_do_action(requester=requester, _time_now_s=0.0)
+ )
+ self.assertTrue(success)
|