Add ratelimit callbacks to module API to allow dynamic ratelimiting (#18458)
This commit is contained in:
parent
28f21b4036
commit
9b2bc75ed4
1
changelog.d/18458.feature
Normal file
1
changelog.d/18458.feature
Normal file
@ -0,0 +1 @@
|
||||
Add a new module API callback that allows overriding of per user ratelimits.
|
||||
@ -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)
|
||||
|
||||
33
docs/modules/ratelimit_callbacks.md
Normal file
33
docs/modules/ratelimit_callbacks.md
Normal 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.
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
62
synapse/module_api/callbacks/ratelimit_callbacks.py
Normal file
62
synapse/module_api/callbacks/ratelimit_callbacks.py
Normal 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
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user