Add ratelimit callbacks to module API to allow dynamic ratelimiting (#18458)

This commit is contained in:
Hugh Nimmo-Smith 2025-06-04 13:09:11 +01:00 committed by GitHub
parent 28f21b4036
commit 9b2bc75ed4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 197 additions and 4 deletions

View File

@ -0,0 +1 @@
Add a new module API callback that allows overriding of per user ratelimits.

View File

@ -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)

View File

@ -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.

View File

@ -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

View File

@ -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(

View File

@ -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,

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)