From c2d10393e93734198e99425068b190ce22e69b8f Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Mon, 9 Mar 2026 16:04:15 -0400 Subject: [PATCH 01/46] MSC4140: impose limit of scheduled delayed events Prevent users from having more than a configured number of delayed events scheduled at once. --- synapse/config/experimental.py | 4 ++ synapse/handlers/delayed_events.py | 1 + .../storage/databases/main/delayed_events.py | 18 +++++++- tests/rest/client/test_rooms.py | 43 ++++++++++++++++++- 4 files changed, 64 insertions(+), 2 deletions(-) diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py index a8c9305704e..31d622aee27 100644 --- a/synapse/config/experimental.py +++ b/synapse/config/experimental.py @@ -563,6 +563,10 @@ def read_config( # MSC4133: Custom profile fields self.msc4133_enabled: bool = experimental.get("msc4133_enabled", False) + self.msc4140_max_delayed_events_per_user: int = experimental.get( + "msc4140_max_delayed_events_per_user", 100 + ) + # MSC4143: Matrix RTC Transport using Livekit Backend self.msc4143_enabled: bool = experimental.get("msc4143_enabled", False) diff --git a/synapse/handlers/delayed_events.py b/synapse/handlers/delayed_events.py index 7e41716f1e2..cc04bade11b 100644 --- a/synapse/handlers/delayed_events.py +++ b/synapse/handlers/delayed_events.py @@ -385,6 +385,7 @@ async def add( origin_server_ts=origin_server_ts, content=content, delay=delay, + limit=self._config.experimental.msc4140_max_delayed_events_per_user, sticky_duration_ms=sticky_duration_ms, ) diff --git a/synapse/storage/databases/main/delayed_events.py b/synapse/storage/databases/main/delayed_events.py index 1727f589e2a..203379d5487 100644 --- a/synapse/storage/databases/main/delayed_events.py +++ b/synapse/storage/databases/main/delayed_events.py @@ -17,7 +17,7 @@ import attr -from synapse.api.errors import NotFoundError +from synapse.api.errors import LimitExceededError, NotFoundError from synapse.storage._base import SQLBaseStore, db_to_json from synapse.storage.database import ( DatabasePool, @@ -123,6 +123,7 @@ async def add_delayed_event( origin_server_ts: int | None, content: JsonDict, delay: int, + limit: int, sticky_duration_ms: int | None, ) -> tuple[DelayID, Timestamp]: """ @@ -131,11 +132,26 @@ async def add_delayed_event( Returns: The generated ID assigned to the added delayed event, and the send time of the next delayed event to be sent, which is either the event just added or one added earlier. + + Raises: + LimitExceededError: if the user has reached the limit of + how many delayed events they may have scheduled at once. """ delay_id = _generate_delay_id() send_ts = Timestamp(creation_ts + delay) def add_delayed_event_txn(txn: LoggingTransaction) -> Timestamp: + num_existing = self.db_pool.simple_select_one_onecol_txn( + txn, + table="delayed_events", + keyvalues={"user_localpart": user_localpart}, + retcol="COUNT(*)", + ) + if num_existing >= limit: + e = LimitExceededError("add_delayed_event") + e.msg = "The maximum number of delayed events has been reached." + raise e + self.db_pool.simple_insert_txn( txn, table="delayed_events", diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index f85c9939ce4..d000fcdfb0a 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -2456,6 +2456,40 @@ def test_delayed_event_exceeds_max_delay(self) -> None: channel.json_body, ) + @unittest.override_config( + { + "max_event_delay_duration": "24h", + "experimental_features": { + "msc4140_max_delayed_events_per_user": 0, + }, + } + ) + def test_delayed_event_user_limit_exceeded(self) -> None: + """Test that users cannot have more delayed events scheduled at once than allowed.""" + self.assertEqual( + self.hs.config.experimental.msc4140_max_delayed_events_per_user, 0 + ) + channel = self.make_request( + "PUT", + ( + "rooms/%s/send/m.room.message/mid1?org.matrix.msc4140.delay=2000" + % self.room_id + ).encode("ascii"), + {"body": "test", "msgtype": "m.text"}, + ) + self.assertEqual(HTTPStatus.TOO_MANY_REQUESTS, channel.code, channel.result) + self.assertEqual( + Codes.LIMIT_EXCEEDED, + channel.json_body["errcode"], + channel.json_body, + ) + # Want a custom error message to have been set + self.assertNotEqual( + "Too Many Requests", + channel.json_body["error"], + channel.json_body, + ) + @unittest.override_config({"max_event_delay_duration": "24h"}) def test_delayed_event_with_negative_delay(self) -> None: """Test that sending a delayed event fails if its delay is negative.""" @@ -2472,7 +2506,14 @@ def test_delayed_event_with_negative_delay(self) -> None: Codes.INVALID_PARAM, channel.json_body["errcode"], channel.json_body ) - @unittest.override_config({"max_event_delay_duration": "24h"}) + @unittest.override_config( + { + "max_event_delay_duration": "24h", + "experimental_features": { + "msc4140_max_delayed_events_per_user": 1, + }, + } + ) def test_send_delayed_message_event(self) -> None: """Test sending a valid delayed message event.""" channel = self.make_request( From c70c428d7f7af2ff1b90e46fb1de56260abb08a4 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Mon, 9 Mar 2026 16:05:47 -0400 Subject: [PATCH 02/46] MSC4140: update error codes to match latest MSC Also don't return a custom error (that was never in the MSC) when adding a delayed event if not enabled in the config --- synapse/rest/client/room.py | 9 ++++----- tests/rest/client/test_rooms.py | 13 ++++++++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/synapse/rest/client/room.py b/synapse/rest/client/room.py index 9172bfcb4ed..05a773cd966 100644 --- a/synapse/rest/client/room.py +++ b/synapse/rest/client/room.py @@ -539,19 +539,18 @@ def _parse_request_delay( raise SynapseError( HTTPStatus.BAD_REQUEST, "Delayed events are not supported on this server", - Codes.UNKNOWN, + "ORG.MATRIX.MSC4140_MAX_DELAY_EXCEEDED", { - "org.matrix.msc4140.errcode": "M_MAX_DELAY_UNSUPPORTED", + "max_delay": 0, }, ) if delay > max_delay: raise SynapseError( HTTPStatus.BAD_REQUEST, "The requested delay exceeds the allowed maximum.", - Codes.UNKNOWN, + "ORG.MATRIX.MSC4140_MAX_DELAY_EXCEEDED", { - "org.matrix.msc4140.errcode": "M_MAX_DELAY_EXCEEDED", - "org.matrix.msc4140.max_delay": max_delay, + "max_delay": max_delay, }, ) return delay diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index d000fcdfb0a..6290a26cc85 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -2419,7 +2419,10 @@ def test_send_delayed_invalid_event(self) -> None: {}, ) self.assertEqual(HTTPStatus.BAD_REQUEST, channel.code, channel.result) - self.assertNotIn("org.matrix.msc4140.errcode", channel.json_body) + self.assertTrue( + channel.json_body.get("errcode", "").startswith("M_"), + channel.json_body, + ) def test_delayed_event_unsupported_by_default(self) -> None: """Test that sending a delayed event is unsupported with the default config.""" @@ -2433,8 +2436,8 @@ def test_delayed_event_unsupported_by_default(self) -> None: ) self.assertEqual(HTTPStatus.BAD_REQUEST, channel.code, channel.result) self.assertEqual( - "M_MAX_DELAY_UNSUPPORTED", - channel.json_body.get("org.matrix.msc4140.errcode"), + "ORG.MATRIX.MSC4140_MAX_DELAY_EXCEEDED", + channel.json_body.get("errcode"), channel.json_body, ) @@ -2451,8 +2454,8 @@ def test_delayed_event_exceeds_max_delay(self) -> None: ) self.assertEqual(HTTPStatus.BAD_REQUEST, channel.code, channel.result) self.assertEqual( - "M_MAX_DELAY_EXCEEDED", - channel.json_body.get("org.matrix.msc4140.errcode"), + "ORG.MATRIX.MSC4140_MAX_DELAY_EXCEEDED", + channel.json_body.get("errcode"), channel.json_body, ) From 3b51b480a80bffbe8e86666fe36666dc904f7139 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Mon, 9 Mar 2026 16:25:56 -0400 Subject: [PATCH 03/46] Add changelogs --- changelog.d/19539.bugfix | 1 + changelog.d/19539.feature | 1 + 2 files changed, 2 insertions(+) create mode 100644 changelog.d/19539.bugfix create mode 100644 changelog.d/19539.feature diff --git a/changelog.d/19539.bugfix b/changelog.d/19539.bugfix new file mode 100644 index 00000000000..41beb3a179b --- /dev/null +++ b/changelog.d/19539.bugfix @@ -0,0 +1 @@ +[MSC4140: Cancellable delayed events](https://github.com/matrix-org/matrix-spec-proposals/pull/4140): Update error responses to match their format in the current draft of the MSC. diff --git a/changelog.d/19539.feature b/changelog.d/19539.feature new file mode 100644 index 00000000000..93eb4cc1b97 --- /dev/null +++ b/changelog.d/19539.feature @@ -0,0 +1 @@ +[MSC4140: Cancellable delayed events](https://github.com/matrix-org/matrix-spec-proposals/pull/4140): Limit how many delayed events a user may have scheduled at once. From ac2bed918f9a51604eceee6a8cd185178bd83396 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Thu, 12 Mar 2026 08:43:46 -0400 Subject: [PATCH 04/46] Assert against variable message, not literal --- tests/rest/client/test_rooms.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index 6290a26cc85..d5256c91725 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -42,7 +42,7 @@ PublicRoomsFilterFields, RoomTypes, ) -from synapse.api.errors import Codes, HttpResponseException +from synapse.api.errors import Codes, HttpResponseException, LimitExceededError from synapse.api.room_versions import RoomVersions from synapse.appservice import ApplicationService from synapse.events import EventBase, make_event_from_dict @@ -2488,7 +2488,7 @@ def test_delayed_event_user_limit_exceeded(self) -> None: ) # Want a custom error message to have been set self.assertNotEqual( - "Too Many Requests", + LimitExceededError("test_delayed_event_user_limit_exceeded").msg, channel.json_body["error"], channel.json_body, ) From e02f554dd414d817ec118594d9b3633e58270f92 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Thu, 12 Mar 2026 09:25:05 -0400 Subject: [PATCH 05/46] Put MSC4140 config near other one & allow stable Set the new "max_delayed_events_per_user" config in the same module as where the other MSC4140 config ("max_event_delay_duration") is set. Also give it aliases for experimental & stable usage. --- synapse/config/experimental.py | 4 ---- synapse/config/server.py | 7 +++++++ synapse/handlers/delayed_events.py | 2 +- tests/rest/client/test_rooms.py | 4 +--- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py index 31d622aee27..a8c9305704e 100644 --- a/synapse/config/experimental.py +++ b/synapse/config/experimental.py @@ -563,10 +563,6 @@ def read_config( # MSC4133: Custom profile fields self.msc4133_enabled: bool = experimental.get("msc4133_enabled", False) - self.msc4140_max_delayed_events_per_user: int = experimental.get( - "msc4140_max_delayed_events_per_user", 100 - ) - # MSC4143: Matrix RTC Transport using Livekit Backend self.msc4143_enabled: bool = experimental.get("msc4143_enabled", False) diff --git a/synapse/config/server.py b/synapse/config/server.py index ca94c224ea5..c371ad9a7e4 100644 --- a/synapse/config/server.py +++ b/synapse/config/server.py @@ -918,6 +918,13 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None: else: self.max_event_delay_ms = None + self.max_delayed_events_per_user: int = config.get( + "experimental_features", {} + ).get( + "msc4140_max_delayed_events_per_user", + config.get("max_delayed_events_per_user", 100), + ) + def has_tls_listener(self) -> bool: return any(listener.is_tls() for listener in self.listeners) diff --git a/synapse/handlers/delayed_events.py b/synapse/handlers/delayed_events.py index cc04bade11b..1826ad2bed6 100644 --- a/synapse/handlers/delayed_events.py +++ b/synapse/handlers/delayed_events.py @@ -385,7 +385,7 @@ async def add( origin_server_ts=origin_server_ts, content=content, delay=delay, - limit=self._config.experimental.msc4140_max_delayed_events_per_user, + limit=self._config.server.max_delayed_events_per_user, sticky_duration_ms=sticky_duration_ms, ) diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index d5256c91725..e8ccb68c86e 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -2469,9 +2469,7 @@ def test_delayed_event_exceeds_max_delay(self) -> None: ) def test_delayed_event_user_limit_exceeded(self) -> None: """Test that users cannot have more delayed events scheduled at once than allowed.""" - self.assertEqual( - self.hs.config.experimental.msc4140_max_delayed_events_per_user, 0 - ) + self.assertEqual(self.hs.config.server.max_delayed_events_per_user, 0) channel = self.make_request( "PUT", ( From 2e01d7fec28402f220ea9e9aa024091660345208 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 13 Mar 2026 08:12:48 -0400 Subject: [PATCH 06/46] In test, assign default error message to variable in an attempt to better explain what it is and why it is being examined --- tests/rest/client/test_rooms.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index e8ccb68c86e..e3755d12b84 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -2485,8 +2485,9 @@ def test_delayed_event_user_limit_exceeded(self) -> None: channel.json_body, ) # Want a custom error message to have been set + default_limit_exceeded_error_msg = LimitExceededError("").msg self.assertNotEqual( - LimitExceededError("test_delayed_event_user_limit_exceeded").msg, + default_limit_exceeded_error_msg, channel.json_body["error"], channel.json_body, ) From 79a0218af2a8c2c91807c031e6f6e847ebb8e519 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 13 Mar 2026 08:24:38 -0400 Subject: [PATCH 07/46] Explain reason for location of experimental config --- synapse/config/server.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/synapse/config/server.py b/synapse/config/server.py index c371ad9a7e4..d1049c54985 100644 --- a/synapse/config/server.py +++ b/synapse/config/server.py @@ -918,6 +918,8 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None: else: self.max_event_delay_ms = None + # The maximum number of delayed events a user may have scheduled at a time. + # (Defined here despite being experimental to be near the other MSC4140 config) self.max_delayed_events_per_user: int = config.get( "experimental_features", {} ).get( From a614ebb514c3f32cd9318e79469f6b981a8089a9 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 13 Mar 2026 08:27:17 -0400 Subject: [PATCH 08/46] Leave new config as experimental only Don't want a stable alias for it yet, as it may change --- synapse/config/server.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/synapse/config/server.py b/synapse/config/server.py index d1049c54985..72e8e97bd97 100644 --- a/synapse/config/server.py +++ b/synapse/config/server.py @@ -922,10 +922,7 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None: # (Defined here despite being experimental to be near the other MSC4140 config) self.max_delayed_events_per_user: int = config.get( "experimental_features", {} - ).get( - "msc4140_max_delayed_events_per_user", - config.get("max_delayed_events_per_user", 100), - ) + ).get("msc4140_max_delayed_events_per_user", 100) def has_tls_listener(self) -> bool: return any(listener.is_tls() for listener in self.listeners) From 448bf175c49398f2f4634ce8d066f2625ae1cd2d Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Mon, 16 Mar 2026 13:47:16 -0400 Subject: [PATCH 09/46] Set Retry-After on delayed event limit error Set its value to the remaining time until the requesting user's next delayed event will be sent. Also drop test against the error message of the thrown LimitExceededError, as now the Retry-After is there to clearly distinguish the thrown error as being related to a specific delayed event request. --- .../storage/databases/main/delayed_events.py | 19 ++++++- tests/rest/client/test_rooms.py | 57 ++++++++++++------- 2 files changed, 55 insertions(+), 21 deletions(-) diff --git a/synapse/storage/databases/main/delayed_events.py b/synapse/storage/databases/main/delayed_events.py index 203379d5487..fdcf090a688 100644 --- a/synapse/storage/databases/main/delayed_events.py +++ b/synapse/storage/databases/main/delayed_events.py @@ -141,14 +141,29 @@ async def add_delayed_event( send_ts = Timestamp(creation_ts + delay) def add_delayed_event_txn(txn: LoggingTransaction) -> Timestamp: - num_existing = self.db_pool.simple_select_one_onecol_txn( + num_existing: int = self.db_pool.simple_select_one_onecol_txn( txn, table="delayed_events", keyvalues={"user_localpart": user_localpart}, retcol="COUNT(*)", ) if num_existing >= limit: - e = LimitExceededError("add_delayed_event") + next_send_ms: int | None = self.db_pool.simple_select_one_onecol_txn( + txn, + table="delayed_events", + keyvalues={ + "is_processed": False, + "user_localpart": user_localpart, + }, + retcol="MIN(send_ts)", + allow_none=True, + ) + e = LimitExceededError( + limiter_name="add_delayed_event", + retry_after_ms=next_send_ms - creation_ts + if next_send_ms is not None + else None, + ) e.msg = "The maximum number of delayed events has been reached." raise e diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index e3755d12b84..91fcc3d7c1e 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -24,6 +24,7 @@ """Tests REST events for /rooms paths.""" import json +import math from http import HTTPStatus from typing import Any, Iterable, Literal from unittest.mock import AsyncMock, Mock, call, patch @@ -42,7 +43,7 @@ PublicRoomsFilterFields, RoomTypes, ) -from synapse.api.errors import Codes, HttpResponseException, LimitExceededError +from synapse.api.errors import Codes, HttpResponseException from synapse.api.room_versions import RoomVersions from synapse.appservice import ApplicationService from synapse.events import EventBase, make_event_from_dict @@ -2463,34 +2464,59 @@ def test_delayed_event_exceeds_max_delay(self) -> None: { "max_event_delay_duration": "24h", "experimental_features": { - "msc4140_max_delayed_events_per_user": 0, + "msc4140_max_delayed_events_per_user": 1, }, } ) def test_delayed_event_user_limit_exceeded(self) -> None: """Test that users cannot have more delayed events scheduled at once than allowed.""" - self.assertEqual(self.hs.config.server.max_delayed_events_per_user, 0) - channel = self.make_request( - "PUT", + send_after_ms = 15000 + args = ( + "POST", ( - "rooms/%s/send/m.room.message/mid1?org.matrix.msc4140.delay=2000" + f"rooms/%s/send/m.room.message?org.matrix.msc4140.delay={send_after_ms}" % self.room_id ).encode("ascii"), {"body": "test", "msgtype": "m.text"}, ) + channel = self.make_request(*args) + self.assertEqual(HTTPStatus.OK, channel.code, channel.result) + + wait_ms = 2000 + self.reactor.advance(wait_ms / 1000.0) + channel = self.make_request(*args) self.assertEqual(HTTPStatus.TOO_MANY_REQUESTS, channel.code, channel.result) self.assertEqual( Codes.LIMIT_EXCEEDED, channel.json_body["errcode"], channel.json_body, ) - # Want a custom error message to have been set - default_limit_exceeded_error_msg = LimitExceededError("").msg - self.assertNotEqual( - default_limit_exceeded_error_msg, - channel.json_body["error"], + step_ms = 100 # The simulated duration of each make_request + expected_retry_after_ms = send_after_ms - wait_ms - step_ms + self.assertEqual( + expected_retry_after_ms, + channel.json_body["retry_after_ms"], channel.json_body, ) + retry_header = channel.headers.getRawHeaders("Retry-After") + assert retry_header + self.assertSequenceEqual( + [str(math.ceil(expected_retry_after_ms / 1000))], + retry_header, + ) + + # Confirm that ratelimit overrides do not unblock this kind of limit + self.get_success( + self.hs.get_datastores().main.set_ratelimit_for_user(self.user_id, 0, 0) + ) + channel = self.make_request(*args) + self.assertEqual(HTTPStatus.TOO_MANY_REQUESTS, channel.code, channel.result) + self.assertIn("retry_after_ms", channel.json_body) + assert channel.headers.getRawHeaders("Retry-After") + + self.reactor.advance(expected_retry_after_ms) + channel = self.make_request(*args) + self.assertEqual(HTTPStatus.OK, channel.code, channel.result) @unittest.override_config({"max_event_delay_duration": "24h"}) def test_delayed_event_with_negative_delay(self) -> None: @@ -2508,14 +2534,7 @@ def test_delayed_event_with_negative_delay(self) -> None: Codes.INVALID_PARAM, channel.json_body["errcode"], channel.json_body ) - @unittest.override_config( - { - "max_event_delay_duration": "24h", - "experimental_features": { - "msc4140_max_delayed_events_per_user": 1, - }, - } - ) + @unittest.override_config({"max_event_delay_duration": "24h"}) def test_send_delayed_message_event(self) -> None: """Test sending a valid delayed message event.""" channel = self.make_request( From 43e14e9a28b747b5da7f8ce9f9009ac098c42292 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Mon, 16 Mar 2026 13:51:17 -0400 Subject: [PATCH 10/46] Enforce max delayed event config to be positive If delayed events are to be disabled, it should be done by leaving the max_event_delay_duration config unspecified, not by setting the delayed event limit to 0. Also add test coverage, and tweak the error thrown for max_delay_event_ms to be consistent with the newly-added error. --- synapse/config/server.py | 12 +++++- .../storage/databases/main/delayed_events.py | 8 ++-- tests/config/test_server.py | 41 +++++++++++++++++++ 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/synapse/config/server.py b/synapse/config/server.py index 72e8e97bd97..cdd7de6700e 100644 --- a/synapse/config/server.py +++ b/synapse/config/server.py @@ -914,7 +914,9 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None: max_event_delay_duration ) if self.max_event_delay_ms <= 0: - raise ConfigError("max_event_delay_duration must be a positive value") + raise ConfigError( + "Expected a positive value", ("max_event_delay_duration",) + ) else: self.max_event_delay_ms = None @@ -923,6 +925,14 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None: self.max_delayed_events_per_user: int = config.get( "experimental_features", {} ).get("msc4140_max_delayed_events_per_user", 100) + if ( + not isinstance(self.max_delayed_events_per_user, int) + or self.max_delayed_events_per_user <= 0 + ): + raise ConfigError( + "Expected a positive value", + ("experimental", "msc4140_max_delayed_events_per_user"), + ) def has_tls_listener(self) -> bool: return any(listener.is_tls() for listener in self.listeners) diff --git a/synapse/storage/databases/main/delayed_events.py b/synapse/storage/databases/main/delayed_events.py index fdcf090a688..4519ae31a73 100644 --- a/synapse/storage/databases/main/delayed_events.py +++ b/synapse/storage/databases/main/delayed_events.py @@ -137,6 +137,7 @@ async def add_delayed_event( LimitExceededError: if the user has reached the limit of how many delayed events they may have scheduled at once. """ + assert limit > 0 # Should be enforced at config read time delay_id = _generate_delay_id() send_ts = Timestamp(creation_ts + delay) @@ -148,7 +149,7 @@ def add_delayed_event_txn(txn: LoggingTransaction) -> Timestamp: retcol="COUNT(*)", ) if num_existing >= limit: - next_send_ms: int | None = self.db_pool.simple_select_one_onecol_txn( + next_send_ms: int = self.db_pool.simple_select_one_onecol_txn( txn, table="delayed_events", keyvalues={ @@ -156,13 +157,10 @@ def add_delayed_event_txn(txn: LoggingTransaction) -> Timestamp: "user_localpart": user_localpart, }, retcol="MIN(send_ts)", - allow_none=True, ) e = LimitExceededError( limiter_name="add_delayed_event", - retry_after_ms=next_send_ms - creation_ts - if next_send_ms is not None - else None, + retry_after_ms=next_send_ms - creation_ts, ) e.msg = "The maximum number of delayed events has been reached." raise e diff --git a/tests/config/test_server.py b/tests/config/test_server.py index d3c59ae14ca..e723ee19cad 100644 --- a/tests/config/test_server.py +++ b/tests/config/test_server.py @@ -18,11 +18,13 @@ # # + import yaml from synapse.config._base import ConfigError, RootConfig from synapse.config.homeserver import HomeServerConfig from synapse.config.server import ServerConfig, generate_ip_set, is_threepid_reserved +from synapse.types import JsonDict from tests import unittest @@ -189,6 +191,45 @@ def test_listeners_set_correctly_open_private_ports_true(self) -> None: self.assertEqual(conf["listeners"], expected_listeners) + def test_max_delayed_events_enforces_positive(self) -> None: + def generate_config(value: int) -> JsonDict: + return {"max_event_delay_duration": value} + + _read_config(generate_config(1)) + + with self.assertRaises(ConfigError): + _read_config(generate_config(0)) + + with self.assertRaises(ConfigError): + _read_config(generate_config(-1)) + + def test_max_delayed_events_per_user_enforces_positive(self) -> None: + def generate_config(value: int) -> JsonDict: + return { + "experimental_features": {"msc4140_max_delayed_events_per_user": value} + } + + _read_config(generate_config(1)) + + with self.assertRaises(ConfigError): + _read_config(generate_config(0)) + + with self.assertRaises(ConfigError): + _read_config(generate_config(-1)) + + +def _read_config(config_values: JsonDict) -> None: + ServerConfig(RootConfig()).read_config( + yaml.safe_load( + HomeServerConfig().generate_config( + config_dir_path="CONFDIR", + data_dir_path="/data_dir_path", + server_name="che.org", + ) + ) + | config_values + ) + class GenerateIpSetTestCase(unittest.TestCase): def test_empty(self) -> None: From f69ddc174e9ab4131e6693d31e97907ee8c3ce8e Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Mon, 16 Mar 2026 13:58:07 -0400 Subject: [PATCH 11/46] Update copyright dates --- README.rst | 2 +- synapse/config/server.py | 3 ++- synapse/handlers/delayed_events.py | 3 ++- synapse/rest/client/room.py | 3 ++- synapse/storage/databases/main/delayed_events.py | 3 ++- tests/config/test_server.py | 3 ++- tests/rest/client/test_rooms.py | 3 ++- 7 files changed, 13 insertions(+), 7 deletions(-) diff --git a/README.rst b/README.rst index 4db5d461a84..532e48f166f 100644 --- a/README.rst +++ b/README.rst @@ -104,7 +104,7 @@ Copyright and Licensing | Copyright 2014–2017 OpenMarket Ltd | Copyright 2017 Vector Creations Ltd | Copyright 2017–2025 New Vector Ltd - | Copyright 2025 Element Creations Ltd + | Copyright 2025-2026 Element Creations Ltd This software is dual-licensed by Element Creations Ltd (Element). It can be used either: diff --git a/synapse/config/server.py b/synapse/config/server.py index cdd7de6700e..414d257276c 100644 --- a/synapse/config/server.py +++ b/synapse/config/server.py @@ -2,7 +2,8 @@ # This file is licensed under the Affero General Public License (AGPL) version 3. # # Copyright 2014-2021 The Matrix.org Foundation C.I.C. -# Copyright (C) 2023-2024 New Vector, Ltd +# Copyright (C) 2023-2025 New Vector Ltd +# Copyright (C) 2025-2026 Element Creations 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 diff --git a/synapse/handlers/delayed_events.py b/synapse/handlers/delayed_events.py index 1826ad2bed6..77e66ed006f 100644 --- a/synapse/handlers/delayed_events.py +++ b/synapse/handlers/delayed_events.py @@ -1,7 +1,8 @@ # # This file is licensed under the Affero General Public License (AGPL) version 3. # -# Copyright (C) 2024 New Vector, Ltd +# Copyright (C) 2024-2025 New Vector Ltd +# Copyright (C) 2025-2026 Element Creations 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 diff --git a/synapse/rest/client/room.py b/synapse/rest/client/room.py index 05a773cd966..169428f79d5 100644 --- a/synapse/rest/client/room.py +++ b/synapse/rest/client/room.py @@ -2,7 +2,8 @@ # This file is licensed under the Affero General Public License (AGPL) version 3. # # Copyright 2014-2016 OpenMarket Ltd -# Copyright (C) 2023-2024 New Vector, Ltd +# Copyright (C) 2023-2025 New Vector Ltd +# Copyright (C) 2025-2026 Element Creations 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 diff --git a/synapse/storage/databases/main/delayed_events.py b/synapse/storage/databases/main/delayed_events.py index 4519ae31a73..cc8e44bcbe5 100644 --- a/synapse/storage/databases/main/delayed_events.py +++ b/synapse/storage/databases/main/delayed_events.py @@ -1,7 +1,8 @@ # # This file is licensed under the Affero General Public License (AGPL) version 3. # -# Copyright (C) 2024 New Vector, Ltd +# Copyright (C) 2024-2025 New Vector Ltd +# Copyright (C) 2025-2026 Element Creations 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 diff --git a/tests/config/test_server.py b/tests/config/test_server.py index e723ee19cad..924199e16a5 100644 --- a/tests/config/test_server.py +++ b/tests/config/test_server.py @@ -1,7 +1,8 @@ # # This file is licensed under the Affero General Public License (AGPL) version 3. # -# Copyright (C) 2023 New Vector, Ltd +# Copyright (C) 2023-2025 New Vector Ltd +# Copyright (C) 2025-2026 Element Creations 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 diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index 91fcc3d7c1e..2c0ca53d998 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -4,7 +4,8 @@ # Copyright 2019 The Matrix.org Foundation C.I.C. # Copyright 2017 Vector Creations Ltd # Copyright 2014-2016 OpenMarket Ltd -# Copyright (C) 2023-2024 New Vector, Ltd +# Copyright (C) 2023-2025 New Vector Ltd +# Copyright (C) 2025-2026 Element Creations 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 From 437a03428e8a42dfb16cc4389a07e896238817eb Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Tue, 2 Jun 2026 13:17:56 -0400 Subject: [PATCH 12/46] Revert "Update copyright dates" This reverts commit f69ddc174e9ab4131e6693d31e97907ee8c3ce8e. See https://github.com/element-hq/synapse/pull/19539#discussion_r2943131376 --- README.rst | 2 +- synapse/config/server.py | 3 +-- synapse/handlers/delayed_events.py | 3 +-- synapse/rest/client/room.py | 3 +-- synapse/storage/databases/main/delayed_events.py | 3 +-- tests/config/test_server.py | 3 +-- tests/rest/client/test_rooms.py | 3 +-- 7 files changed, 7 insertions(+), 13 deletions(-) diff --git a/README.rst b/README.rst index 532e48f166f..4db5d461a84 100644 --- a/README.rst +++ b/README.rst @@ -104,7 +104,7 @@ Copyright and Licensing | Copyright 2014–2017 OpenMarket Ltd | Copyright 2017 Vector Creations Ltd | Copyright 2017–2025 New Vector Ltd - | Copyright 2025-2026 Element Creations Ltd + | Copyright 2025 Element Creations Ltd This software is dual-licensed by Element Creations Ltd (Element). It can be used either: diff --git a/synapse/config/server.py b/synapse/config/server.py index 414d257276c..cdd7de6700e 100644 --- a/synapse/config/server.py +++ b/synapse/config/server.py @@ -2,8 +2,7 @@ # This file is licensed under the Affero General Public License (AGPL) version 3. # # Copyright 2014-2021 The Matrix.org Foundation C.I.C. -# Copyright (C) 2023-2025 New Vector Ltd -# Copyright (C) 2025-2026 Element Creations Ltd +# Copyright (C) 2023-2024 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 diff --git a/synapse/handlers/delayed_events.py b/synapse/handlers/delayed_events.py index 77e66ed006f..1826ad2bed6 100644 --- a/synapse/handlers/delayed_events.py +++ b/synapse/handlers/delayed_events.py @@ -1,8 +1,7 @@ # # This file is licensed under the Affero General Public License (AGPL) version 3. # -# Copyright (C) 2024-2025 New Vector Ltd -# Copyright (C) 2025-2026 Element Creations Ltd +# Copyright (C) 2024 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 diff --git a/synapse/rest/client/room.py b/synapse/rest/client/room.py index 169428f79d5..05a773cd966 100644 --- a/synapse/rest/client/room.py +++ b/synapse/rest/client/room.py @@ -2,8 +2,7 @@ # This file is licensed under the Affero General Public License (AGPL) version 3. # # Copyright 2014-2016 OpenMarket Ltd -# Copyright (C) 2023-2025 New Vector Ltd -# Copyright (C) 2025-2026 Element Creations Ltd +# Copyright (C) 2023-2024 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 diff --git a/synapse/storage/databases/main/delayed_events.py b/synapse/storage/databases/main/delayed_events.py index cc8e44bcbe5..4519ae31a73 100644 --- a/synapse/storage/databases/main/delayed_events.py +++ b/synapse/storage/databases/main/delayed_events.py @@ -1,8 +1,7 @@ # # This file is licensed under the Affero General Public License (AGPL) version 3. # -# Copyright (C) 2024-2025 New Vector Ltd -# Copyright (C) 2025-2026 Element Creations Ltd +# Copyright (C) 2024 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 diff --git a/tests/config/test_server.py b/tests/config/test_server.py index 924199e16a5..e723ee19cad 100644 --- a/tests/config/test_server.py +++ b/tests/config/test_server.py @@ -1,8 +1,7 @@ # # This file is licensed under the Affero General Public License (AGPL) version 3. # -# Copyright (C) 2023-2025 New Vector Ltd -# Copyright (C) 2025-2026 Element Creations Ltd +# Copyright (C) 2023 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 diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index 2c0ca53d998..91fcc3d7c1e 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -4,8 +4,7 @@ # Copyright 2019 The Matrix.org Foundation C.I.C. # Copyright 2017 Vector Creations Ltd # Copyright 2014-2016 OpenMarket Ltd -# Copyright (C) 2023-2025 New Vector Ltd -# Copyright (C) 2025-2026 Element Creations Ltd +# Copyright (C) 2023-2024 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 From 1b19c138171fd4dd06ff56ceeb03b3df578a9147 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Sun, 7 Jun 2026 17:26:01 -0400 Subject: [PATCH 13/46] Use capabilities to convey delayed event limits See https://github.com/matrix-org/matrix-spec-proposals/blob/toger5/expiring-events-keep-alive/proposals/4140-delayed-events-futures.md#delayed-event-limits-as-a-capability --- synapse/rest/client/capabilities.py | 5 ++++ synapse/rest/client/room.py | 10 ++----- tests/rest/client/test_capabilities.py | 36 ++++++++++++++++++++++++++ tests/rest/client/test_rooms.py | 4 +-- 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/synapse/rest/client/capabilities.py b/synapse/rest/client/capabilities.py index 2be5f5849d7..8c00ecbc901 100644 --- a/synapse/rest/client/capabilities.py +++ b/synapse/rest/client/capabilities.py @@ -109,6 +109,11 @@ async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]: "capabilities" ]["m.profile_fields"] + response["capabilities"]["org.matrix.msc4140.delayed_events"] = { + "max_delay": self.config.server.max_event_delay_ms or 0, + "max_scheduled": self.config.server.max_delayed_events_per_user, + } + if self.config.experimental.msc4267_enabled: response["capabilities"]["org.matrix.msc4267.forget_forced_upon_leave"] = { "enabled": self.config.room.forget_on_leave, diff --git a/synapse/rest/client/room.py b/synapse/rest/client/room.py index fe8e6a3338b..e6301860623 100644 --- a/synapse/rest/client/room.py +++ b/synapse/rest/client/room.py @@ -540,19 +540,13 @@ def _parse_request_delay( raise SynapseError( HTTPStatus.BAD_REQUEST, "Delayed events are not supported on this server", - "ORG.MATRIX.MSC4140_MAX_DELAY_EXCEEDED", - { - "max_delay": 0, - }, + Codes.INVALID_PARAM, ) if delay > max_delay: raise SynapseError( HTTPStatus.BAD_REQUEST, "The requested delay exceeds the allowed maximum.", - "ORG.MATRIX.MSC4140_MAX_DELAY_EXCEEDED", - { - "max_delay": max_delay, - }, + Codes.INVALID_PARAM, ) return delay diff --git a/tests/rest/client/test_capabilities.py b/tests/rest/client/test_capabilities.py index c28e0605b54..709eac3fe11 100644 --- a/tests/rest/client/test_capabilities.py +++ b/tests/rest/client/test_capabilities.py @@ -203,6 +203,42 @@ def test_get_set_avatar_url_capabilities_avatar_url_disabled_msc4133(self) -> No ["avatar_url"], ) + def test_get_delayed_events_capabilities_default_config_msc4140(self) -> None: + access_token = self.login(self.localpart, self.password) + + channel = self.make_request("GET", self.url, access_token=access_token) + capabilities = channel.json_body["capabilities"] + + self.assertEqual(channel.code, HTTPStatus.OK) + self.assertEqual( + capabilities["org.matrix.msc4140.delayed_events"]["max_delay"], 0 + ) + self.assertEqual( + capabilities["org.matrix.msc4140.delayed_events"]["max_scheduled"], 100 + ) + + @override_config( + { + "max_event_delay_duration": "24h", + "experimental_features": { + "msc4140_max_delayed_events_per_user": 50, + }, + } + ) + def test_get_delayed_events_capabilities_custom_config_msc4140(self) -> None: + access_token = self.login(self.localpart, self.password) + + channel = self.make_request("GET", self.url, access_token=access_token) + capabilities = channel.json_body["capabilities"] + + self.assertEqual(channel.code, HTTPStatus.OK) + self.assertEqual( + capabilities["org.matrix.msc4140.delayed_events"]["max_delay"], 86400000 + ) + self.assertEqual( + capabilities["org.matrix.msc4140.delayed_events"]["max_scheduled"], 50 + ) + @override_config({"enable_3pid_changes": False}) def test_get_change_3pid_capabilities_3pid_disabled(self) -> None: """Test if change 3pid is disabled that the server responds it.""" diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index 50bbc62bf49..6c2454046e7 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -2521,7 +2521,7 @@ def test_delayed_event_unsupported_by_default(self) -> None: ) self.assertEqual(HTTPStatus.BAD_REQUEST, channel.code, channel.result) self.assertEqual( - "ORG.MATRIX.MSC4140_MAX_DELAY_EXCEEDED", + Codes.INVALID_PARAM, channel.json_body.get("errcode"), channel.json_body, ) @@ -2539,7 +2539,7 @@ def test_delayed_event_exceeds_max_delay(self) -> None: ) self.assertEqual(HTTPStatus.BAD_REQUEST, channel.code, channel.result) self.assertEqual( - "ORG.MATRIX.MSC4140_MAX_DELAY_EXCEEDED", + Codes.INVALID_PARAM, channel.json_body.get("errcode"), channel.json_body, ) From 267a7e597937c90d930ef1d84382db83818a554b Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Sun, 7 Jun 2026 17:26:47 -0400 Subject: [PATCH 14/46] Allow delayed event limit to be set to 0 Do this as it is intuitive to want to configure a limit of 0 in order to disable delayed events. Update errors & config parsing as appropriate. Also raise the same error for whether the max allowed delay duration or the max number of delayed events is 0. --- synapse/config/server.py | 4 ++-- synapse/rest/client/room.py | 3 +-- .../storage/databases/main/delayed_events.py | 9 ++++++-- tests/config/test_server.py | 17 +++++++------- tests/rest/client/test_rooms.py | 23 +++++++++++++++---- 5 files changed, 38 insertions(+), 18 deletions(-) diff --git a/synapse/config/server.py b/synapse/config/server.py index cdd7de6700e..be7255841a7 100644 --- a/synapse/config/server.py +++ b/synapse/config/server.py @@ -927,10 +927,10 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None: ).get("msc4140_max_delayed_events_per_user", 100) if ( not isinstance(self.max_delayed_events_per_user, int) - or self.max_delayed_events_per_user <= 0 + or self.max_delayed_events_per_user < 0 ): raise ConfigError( - "Expected a positive value", + "'msc4140_max_delayed_events_per_user' must be a non-negative integer", ("experimental", "msc4140_max_delayed_events_per_user"), ) diff --git a/synapse/rest/client/room.py b/synapse/rest/client/room.py index e6301860623..5527f257f0e 100644 --- a/synapse/rest/client/room.py +++ b/synapse/rest/client/room.py @@ -539,8 +539,7 @@ def _parse_request_delay( if max_delay is None: raise SynapseError( HTTPStatus.BAD_REQUEST, - "Delayed events are not supported on this server", - Codes.INVALID_PARAM, + "Sending delayed events has been disallowed", ) if delay > max_delay: raise SynapseError( diff --git a/synapse/storage/databases/main/delayed_events.py b/synapse/storage/databases/main/delayed_events.py index 4519ae31a73..30a4c26dd0c 100644 --- a/synapse/storage/databases/main/delayed_events.py +++ b/synapse/storage/databases/main/delayed_events.py @@ -13,11 +13,12 @@ # import logging +from http import HTTPStatus from typing import TYPE_CHECKING, NewType import attr -from synapse.api.errors import LimitExceededError, NotFoundError +from synapse.api.errors import LimitExceededError, NotFoundError, SynapseError from synapse.storage._base import SQLBaseStore, db_to_json from synapse.storage.database import ( DatabasePool, @@ -137,7 +138,11 @@ async def add_delayed_event( LimitExceededError: if the user has reached the limit of how many delayed events they may have scheduled at once. """ - assert limit > 0 # Should be enforced at config read time + if limit <= 0: + raise SynapseError( + HTTPStatus.BAD_REQUEST, + "Sending delayed events has been disallowed", + ) delay_id = _generate_delay_id() send_ts = Timestamp(creation_ts + delay) diff --git a/tests/config/test_server.py b/tests/config/test_server.py index e723ee19cad..52510480d65 100644 --- a/tests/config/test_server.py +++ b/tests/config/test_server.py @@ -19,6 +19,8 @@ # +from typing import Any + import yaml from synapse.config._base import ConfigError, RootConfig @@ -203,19 +205,18 @@ def generate_config(value: int) -> JsonDict: with self.assertRaises(ConfigError): _read_config(generate_config(-1)) - def test_max_delayed_events_per_user_enforces_positive(self) -> None: - def generate_config(value: int) -> JsonDict: + def test_max_delayed_events_per_user_enforces_non_negative_int(self) -> None: + def generate_config(value: Any) -> JsonDict: return { "experimental_features": {"msc4140_max_delayed_events_per_user": value} } - _read_config(generate_config(1)) - - with self.assertRaises(ConfigError): - _read_config(generate_config(0)) + for allowed_value in (0, 1): + _read_config(generate_config(allowed_value)) - with self.assertRaises(ConfigError): - _read_config(generate_config(-1)) + for disallowed_value in (-1, 0.5): + with self.assertRaises(ConfigError): + _read_config(generate_config(disallowed_value)) def _read_config(config_values: JsonDict) -> None: diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index 6c2454046e7..70d7ac7c8be 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -2520,11 +2520,26 @@ def test_delayed_event_unsupported_by_default(self) -> None: {"body": "test", "msgtype": "m.text"}, ) self.assertEqual(HTTPStatus.BAD_REQUEST, channel.code, channel.result) - self.assertEqual( - Codes.INVALID_PARAM, - channel.json_body.get("errcode"), - channel.json_body, + + @unittest.override_config( + { + "max_event_delay_duration": "24h", + "experimental_features": { + "msc4140_max_delayed_events_per_user": 0, + }, + } + ) + def test_delayed_event_disabled_by_limit(self) -> None: + """Test that delayed events are disabled by configuring the per-user limit to 0.""" + channel = self.make_request( + "PUT", + ( + "rooms/%s/send/m.room.message/mid1?org.matrix.msc4140.delay=2000" + % self.room_id + ).encode("ascii"), + {"body": "test", "msgtype": "m.text"}, ) + self.assertEqual(HTTPStatus.BAD_REQUEST, channel.code, channel.result) @unittest.override_config({"max_event_delay_duration": "1000"}) def test_delayed_event_exceeds_max_delay(self) -> None: From df09f8da8808776a51fb85eaf9f69d02c8773ef0 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Sun, 7 Jun 2026 17:32:18 -0400 Subject: [PATCH 15/46] Rename variable to better indicate it as a time --- synapse/storage/databases/main/delayed_events.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/synapse/storage/databases/main/delayed_events.py b/synapse/storage/databases/main/delayed_events.py index 30a4c26dd0c..e80243c2201 100644 --- a/synapse/storage/databases/main/delayed_events.py +++ b/synapse/storage/databases/main/delayed_events.py @@ -153,8 +153,9 @@ def add_delayed_event_txn(txn: LoggingTransaction) -> Timestamp: keyvalues={"user_localpart": user_localpart}, retcol="COUNT(*)", ) + next_send_ts: Timestamp | None if num_existing >= limit: - next_send_ms: int = self.db_pool.simple_select_one_onecol_txn( + next_send_ts = self.db_pool.simple_select_one_onecol_txn( txn, table="delayed_events", keyvalues={ @@ -165,7 +166,7 @@ def add_delayed_event_txn(txn: LoggingTransaction) -> Timestamp: ) e = LimitExceededError( limiter_name="add_delayed_event", - retry_after_ms=next_send_ms - creation_ts, + retry_after_ms=next_send_ts - creation_ts, ) e.msg = "The maximum number of delayed events has been reached." raise e From 3804db0a87bed46f989bc7822fa4610b7b81acde Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Sun, 7 Jun 2026 18:17:38 -0400 Subject: [PATCH 16/46] Elaborate comment on make_request time step --- tests/rest/client/test_rooms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index 70d7ac7c8be..c5964320bf3 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -2590,7 +2590,7 @@ def test_delayed_event_user_limit_exceeded(self) -> None: channel.json_body["errcode"], channel.json_body, ) - step_ms = 100 # The simulated duration of each make_request + step_ms = 100 # This is the amount of time advanced by a call to make_request expected_retry_after_ms = send_after_ms - wait_ms - step_ms self.assertEqual( expected_retry_after_ms, From 184b32d983fbb101d71ea4c1f755495432a5c238 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Sun, 7 Jun 2026 18:27:44 -0400 Subject: [PATCH 17/46] Remove unnecessary wait in test --- tests/rest/client/test_rooms.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index c5964320bf3..d8c7800b5e1 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -2581,8 +2581,6 @@ def test_delayed_event_user_limit_exceeded(self) -> None: channel = self.make_request(*args) self.assertEqual(HTTPStatus.OK, channel.code, channel.result) - wait_ms = 2000 - self.reactor.advance(wait_ms / 1000.0) channel = self.make_request(*args) self.assertEqual(HTTPStatus.TOO_MANY_REQUESTS, channel.code, channel.result) self.assertEqual( @@ -2591,7 +2589,7 @@ def test_delayed_event_user_limit_exceeded(self) -> None: channel.json_body, ) step_ms = 100 # This is the amount of time advanced by a call to make_request - expected_retry_after_ms = send_after_ms - wait_ms - step_ms + expected_retry_after_ms = send_after_ms - step_ms self.assertEqual( expected_retry_after_ms, channel.json_body["retry_after_ms"], From c19df5d1316b510a8c38f3b94c9b037b6b7544be Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Sun, 7 Jun 2026 23:24:49 -0400 Subject: [PATCH 18/46] Clarify config limits in errors & test comments --- synapse/config/server.py | 3 ++- tests/config/test_server.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/synapse/config/server.py b/synapse/config/server.py index be7255841a7..9bf93e3d87c 100644 --- a/synapse/config/server.py +++ b/synapse/config/server.py @@ -915,7 +915,8 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None: ) if self.max_event_delay_ms <= 0: raise ConfigError( - "Expected a positive value", ("max_event_delay_duration",) + "'max_event_delay_duration' must be a positive value if set", + ("max_event_delay_duration",) ) else: self.max_event_delay_ms = None diff --git a/tests/config/test_server.py b/tests/config/test_server.py index 52510480d65..41ea8fb5b1b 100644 --- a/tests/config/test_server.py +++ b/tests/config/test_server.py @@ -194,6 +194,11 @@ def test_listeners_set_correctly_open_private_ports_true(self) -> None: self.assertEqual(conf["listeners"], expected_listeners) def test_max_delayed_events_enforces_positive(self) -> None: + """ + Test that the configured maximum allowed delay must be a positive value if set, + as per documentation + """ + def generate_config(value: int) -> JsonDict: return {"max_event_delay_duration": value} @@ -206,6 +211,11 @@ def generate_config(value: int) -> JsonDict: _read_config(generate_config(-1)) def test_max_delayed_events_per_user_enforces_non_negative_int(self) -> None: + """ + Test that the configured maximum number of delayed events must be a non-negative value if set, + as a negative limit can never be satisfied + """ + def generate_config(value: Any) -> JsonDict: return { "experimental_features": {"msc4140_max_delayed_events_per_user": value} From 377cac4c400a3ad3c0b9022ce3475aec9d37f81a Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Sun, 7 Jun 2026 23:30:21 -0400 Subject: [PATCH 19/46] Lint --- synapse/config/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/synapse/config/server.py b/synapse/config/server.py index 9bf93e3d87c..70248e66a50 100644 --- a/synapse/config/server.py +++ b/synapse/config/server.py @@ -916,7 +916,7 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None: if self.max_event_delay_ms <= 0: raise ConfigError( "'max_event_delay_duration' must be a positive value if set", - ("max_event_delay_duration",) + ("max_event_delay_duration",), ) else: self.max_event_delay_ms = None From 0b0aab1380b3bfe2e33f010f7c306752bd4a44ff Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 12 Jun 2026 10:46:59 -0400 Subject: [PATCH 20/46] Document special-case error for limit <= 0 --- synapse/storage/databases/main/delayed_events.py | 1 + 1 file changed, 1 insertion(+) diff --git a/synapse/storage/databases/main/delayed_events.py b/synapse/storage/databases/main/delayed_events.py index e80243c2201..9eb73c2d2f2 100644 --- a/synapse/storage/databases/main/delayed_events.py +++ b/synapse/storage/databases/main/delayed_events.py @@ -137,6 +137,7 @@ async def add_delayed_event( Raises: LimitExceededError: if the user has reached the limit of how many delayed events they may have scheduled at once. + SynapseError: if the user is not allowed to schedule any delayed events. """ if limit <= 0: raise SynapseError( From d62f4401db9014f00c486697f6bee8650fd50190 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 12 Jun 2026 10:51:41 -0400 Subject: [PATCH 21/46] Cover case of limit << num existing delayed events --- .../storage/databases/main/delayed_events.py | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/synapse/storage/databases/main/delayed_events.py b/synapse/storage/databases/main/delayed_events.py index 9eb73c2d2f2..344311120af 100644 --- a/synapse/storage/databases/main/delayed_events.py +++ b/synapse/storage/databases/main/delayed_events.py @@ -154,20 +154,28 @@ def add_delayed_event_txn(txn: LoggingTransaction) -> Timestamp: keyvalues={"user_localpart": user_localpart}, retcol="COUNT(*)", ) - next_send_ts: Timestamp | None if num_existing >= limit: - next_send_ts = self.db_pool.simple_select_one_onecol_txn( - txn, - table="delayed_events", - keyvalues={ - "is_processed": False, - "user_localpart": user_localpart, - }, - retcol="MIN(send_ts)", + # Cover case of num_existing > limit, which can happen by reducing + # the configured limit after delayed events have already been added + # (or by calling this method with a limit lower than the configured one) + txn.execute( + """ + SELECT MAX(send_ts) FROM ( + SELECT * FROM delayed_events + WHERE user_localpart = ? AND NOT is_processed + ORDER BY send_ts LIMIT ? + ) + """, + ( + user_localpart, + num_existing - limit + 1, + ), ) + row = txn.fetchone() + assert row e = LimitExceededError( limiter_name="add_delayed_event", - retry_after_ms=next_send_ts - creation_ts, + retry_after_ms=row[0] - creation_ts, ) e.msg = "The maximum number of delayed events has been reached." raise e From 4a9464c8479f42bed5ffcfc3f89764e78e20c37d Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 12 Jun 2026 10:52:54 -0400 Subject: [PATCH 22/46] Clarify test on ratelimit override --- tests/rest/client/test_rooms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index d8c7800b5e1..6fac4659e99 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -2602,7 +2602,7 @@ def test_delayed_event_user_limit_exceeded(self) -> None: retry_header, ) - # Confirm that ratelimit overrides do not unblock this kind of limit + # Confirm that lifing temporal ratelimits doesn't lift this storage-based limit self.get_success( self.hs.get_datastores().main.set_ratelimit_for_user(self.user_id, 0, 0) ) From 79a6f3154eb7361e46d0e6d3a6b2a9d2b7fcb721 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 12 Jun 2026 10:54:10 -0400 Subject: [PATCH 23/46] Simplify delayed event limit test Don't assert the specific value of the Retry-After header, but test only that waiting for its returned duration is sufficient to bypass the limit --- tests/rest/client/test_rooms.py | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index 6fac4659e99..d1aa7220277 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -24,7 +24,6 @@ """Tests REST events for /rooms paths.""" import json -import math from http import HTTPStatus from typing import Any, Iterable, Literal from unittest.mock import AsyncMock, Mock, call, patch @@ -2588,19 +2587,10 @@ def test_delayed_event_user_limit_exceeded(self) -> None: channel.json_body["errcode"], channel.json_body, ) - step_ms = 100 # This is the amount of time advanced by a call to make_request - expected_retry_after_ms = send_after_ms - step_ms - self.assertEqual( - expected_retry_after_ms, - channel.json_body["retry_after_ms"], - channel.json_body, - ) - retry_header = channel.headers.getRawHeaders("Retry-After") - assert retry_header - self.assertSequenceEqual( - [str(math.ceil(expected_retry_after_ms / 1000))], - retry_header, - ) + retry_after_header = channel.headers.getRawHeaders("Retry-After") + assert retry_after_header + retry_after_ms = int(retry_after_header[0]) + assert retry_after_ms > 0 # Confirm that lifing temporal ratelimits doesn't lift this storage-based limit self.get_success( @@ -2611,7 +2601,7 @@ def test_delayed_event_user_limit_exceeded(self) -> None: self.assertIn("retry_after_ms", channel.json_body) assert channel.headers.getRawHeaders("Retry-After") - self.reactor.advance(expected_retry_after_ms) + self.reactor.advance(retry_after_ms) channel = self.make_request(*args) self.assertEqual(HTTPStatus.OK, channel.code, channel.result) From 34c5ce9b5c6c9646bdf1f5fe0132168ace4d1a4e Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 12 Jun 2026 11:01:19 -0400 Subject: [PATCH 24/46] Use consistent keyvalues when applying limit --- synapse/storage/databases/main/delayed_events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/synapse/storage/databases/main/delayed_events.py b/synapse/storage/databases/main/delayed_events.py index 344311120af..95f8d703473 100644 --- a/synapse/storage/databases/main/delayed_events.py +++ b/synapse/storage/databases/main/delayed_events.py @@ -162,7 +162,7 @@ def add_delayed_event_txn(txn: LoggingTransaction) -> Timestamp: """ SELECT MAX(send_ts) FROM ( SELECT * FROM delayed_events - WHERE user_localpart = ? AND NOT is_processed + WHERE user_localpart = ? ORDER BY send_ts LIMIT ? ) """, From 2eea7f5872aa070d8a73a72848e2dc27fe91ff5e Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 12 Jun 2026 11:29:26 -0400 Subject: [PATCH 25/46] Use "err" instead of "e" Co-authored-by: Eric Eastwood --- synapse/storage/databases/main/delayed_events.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/synapse/storage/databases/main/delayed_events.py b/synapse/storage/databases/main/delayed_events.py index 95f8d703473..c067331eff1 100644 --- a/synapse/storage/databases/main/delayed_events.py +++ b/synapse/storage/databases/main/delayed_events.py @@ -173,12 +173,12 @@ def add_delayed_event_txn(txn: LoggingTransaction) -> Timestamp: ) row = txn.fetchone() assert row - e = LimitExceededError( + err = LimitExceededError( limiter_name="add_delayed_event", - retry_after_ms=row[0] - creation_ts, + retry_after_ms=next_send_ts - creation_ts, ) e.msg = "The maximum number of delayed events has been reached." - raise e + raise err self.db_pool.simple_insert_txn( txn, From 87ce5f36a1892596a6809d39f73078035b5cc70b Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 12 Jun 2026 11:47:50 -0400 Subject: [PATCH 26/46] Fixups --- synapse/storage/databases/main/delayed_events.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/synapse/storage/databases/main/delayed_events.py b/synapse/storage/databases/main/delayed_events.py index c067331eff1..9ea0f44ac13 100644 --- a/synapse/storage/databases/main/delayed_events.py +++ b/synapse/storage/databases/main/delayed_events.py @@ -175,9 +175,9 @@ def add_delayed_event_txn(txn: LoggingTransaction) -> Timestamp: assert row err = LimitExceededError( limiter_name="add_delayed_event", - retry_after_ms=next_send_ts - creation_ts, + retry_after_ms=row[0] - creation_ts, ) - e.msg = "The maximum number of delayed events has been reached." + err.msg = "The maximum number of delayed events has been reached." raise err self.db_pool.simple_insert_txn( From e1a0ba4b7863a805ad2500d49e7a10cc7e227d46 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 12 Jun 2026 11:49:36 -0400 Subject: [PATCH 27/46] Test limit << num existing delayed events --- tests/rest/client/test_rooms.py | 44 +++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index d1aa7220277..984109ece68 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -2605,6 +2605,50 @@ def test_delayed_event_user_limit_exceeded(self) -> None: channel = self.make_request(*args) self.assertEqual(HTTPStatus.OK, channel.code, channel.result) + @unittest.override_config( + { + "max_event_delay_duration": "24h", + "experimental_features": { + "msc4140_max_delayed_events_per_user": 5, + }, + } + ) + def test_delayed_event_custom_user_limit_exceeded(self) -> None: + """ + Test that delayed event limits work propertly when + the number of already scheduled events exceeds the limit. + """ + for i in range(3): + send_after_ms = 1000 * i + args = ( + "POST", + ( + f"rooms/%s/send/m.room.message?org.matrix.msc4140.delay={send_after_ms}" + % self.room_id + ).encode("ascii"), + {"body": f"test{i}", "msgtype": "m.text"}, + ) + channel = self.make_request(*args) + self.assertEqual(HTTPStatus.OK, channel.code, channel.result) + + self.hs.config.server.max_delayed_events_per_user = 1 + + channel = self.make_request(*args) + self.assertEqual(HTTPStatus.TOO_MANY_REQUESTS, channel.code, channel.result) + self.assertEqual( + Codes.LIMIT_EXCEEDED, + channel.json_body["errcode"], + channel.json_body, + ) + retry_after_header = channel.headers.getRawHeaders("Retry-After") + assert retry_after_header + retry_after_ms = int(retry_after_header[0]) + assert retry_after_ms > 0 + + self.reactor.advance(retry_after_ms) + channel = self.make_request(*args) + self.assertEqual(HTTPStatus.OK, channel.code, channel.result) + @unittest.override_config({"max_event_delay_duration": "24h"}) def test_delayed_event_with_negative_delay(self) -> None: """Test that sending a delayed event fails if its delay is negative.""" From 1a2895ba0ad66956e4655f2987a4cd159686b8c9 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 12 Jun 2026 15:39:43 -0400 Subject: [PATCH 28/46] Add alias to sub-SELECT, needed for PostgreSQL <16 --- synapse/storage/databases/main/delayed_events.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/synapse/storage/databases/main/delayed_events.py b/synapse/storage/databases/main/delayed_events.py index 9ea0f44ac13..5857c61ce86 100644 --- a/synapse/storage/databases/main/delayed_events.py +++ b/synapse/storage/databases/main/delayed_events.py @@ -158,13 +158,14 @@ def add_delayed_event_txn(txn: LoggingTransaction) -> Timestamp: # Cover case of num_existing > limit, which can happen by reducing # the configured limit after delayed events have already been added # (or by calling this method with a limit lower than the configured one) + # TODO Remove "AS subquery" after dropping support for PostgreSQL <16 txn.execute( """ SELECT MAX(send_ts) FROM ( SELECT * FROM delayed_events WHERE user_localpart = ? ORDER BY send_ts LIMIT ? - ) + ) AS subquery """, ( user_localpart, From 7bc51d7f305c25e896ae0314cd4b92cf13cae966 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Wed, 17 Jun 2026 14:54:37 -0400 Subject: [PATCH 29/46] Apply suggestions from code review Co-authored-by: Eric Eastwood --- synapse/storage/databases/main/delayed_events.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/synapse/storage/databases/main/delayed_events.py b/synapse/storage/databases/main/delayed_events.py index 5857c61ce86..1315afbc47b 100644 --- a/synapse/storage/databases/main/delayed_events.py +++ b/synapse/storage/databases/main/delayed_events.py @@ -155,16 +155,15 @@ def add_delayed_event_txn(txn: LoggingTransaction) -> Timestamp: retcol="COUNT(*)", ) if num_existing >= limit: - # Cover case of num_existing > limit, which can happen by reducing - # the configured limit after delayed events have already been added - # (or by calling this method with a limit lower than the configured one) - # TODO Remove "AS subquery" after dropping support for PostgreSQL <16 + # + # FIXME: Remove "AS subquery" after dropping support for PostgreSQL <16 txn.execute( """ SELECT MAX(send_ts) FROM ( SELECT * FROM delayed_events WHERE user_localpart = ? - ORDER BY send_ts LIMIT ? + ORDER BY send_ts ASC + LIMIT ? ) AS subquery """, ( From 56439efcb022e004edc6ed8d705fcfe31e255682 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Wed, 17 Jun 2026 14:55:47 -0400 Subject: [PATCH 30/46] Improve test comments Co-authored-by: Eric Eastwood --- tests/rest/client/test_rooms.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index 984109ece68..baf5b2aa3f1 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -2616,7 +2616,10 @@ def test_delayed_event_user_limit_exceeded(self) -> None: def test_delayed_event_custom_user_limit_exceeded(self) -> None: """ Test that delayed event limits work propertly when - the number of already scheduled events exceeds the limit. + the number of already scheduled events exceeds the configured limit. + + This can be invoked by the server admin lowering the configured limit & restarting the server + while a user has fewer scheduled delayed events than the old limit, but more than the new limit. """ for i in range(3): send_after_ms = 1000 * i @@ -2631,6 +2634,7 @@ def test_delayed_event_custom_user_limit_exceeded(self) -> None: channel = self.make_request(*args) self.assertEqual(HTTPStatus.OK, channel.code, channel.result) + # Simulate lowering the configured limit & applying it with a server restart self.hs.config.server.max_delayed_events_per_user = 1 channel = self.make_request(*args) From d2e84b7c3fa04a942d1e55fbd14b0eb2727c94b7 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Wed, 17 Jun 2026 16:55:52 -0400 Subject: [PATCH 31/46] Fix typo in test docstring --- tests/rest/client/test_rooms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index baf5b2aa3f1..22f3a9be0df 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -2615,7 +2615,7 @@ def test_delayed_event_user_limit_exceeded(self) -> None: ) def test_delayed_event_custom_user_limit_exceeded(self) -> None: """ - Test that delayed event limits work propertly when + Test that delayed event limits work properly when the number of already scheduled events exceeds the configured limit. This can be invoked by the server admin lowering the configured limit & restarting the server From af16899bf8e13b2bf11c5e52e69395f1d2fc9be5 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Wed, 17 Jun 2026 16:55:59 -0400 Subject: [PATCH 32/46] Move all delayed event limit processing in handler --- synapse/handlers/delayed_events.py | 26 +++++++++++++-- synapse/rest/client/room.py | 32 +++---------------- .../storage/databases/main/delayed_events.py | 5 --- 3 files changed, 28 insertions(+), 35 deletions(-) diff --git a/synapse/handlers/delayed_events.py b/synapse/handlers/delayed_events.py index 03920580cdd..21430b9f9b0 100644 --- a/synapse/handlers/delayed_events.py +++ b/synapse/handlers/delayed_events.py @@ -13,12 +13,13 @@ # import logging +from http import HTTPStatus from typing import TYPE_CHECKING, Optional from twisted.internet.interfaces import IDelayedCall from synapse.api.constants import EventTypes, StickyEvent, StickyEventField -from synapse.api.errors import ShadowBanError, SynapseError +from synapse.api.errors import Codes, ShadowBanError, SynapseError from synapse.api.ratelimiting import Ratelimiter from synapse.config.workers import MAIN_PROCESS_INSTANCE_NAME from synapse.http.site import SynapseRequest @@ -353,13 +354,32 @@ async def add( Returns: The ID of the added delayed event. Raises: - SynapseError: if the delayed event fails validation checks. + SynapseError: if the delayed event fails validation checks, or + if the requested delay is longer than allowed, or + if sending delayed events has been disallowed entirely. """ # Use standard request limiter for scheduling new delayed events. # TODO: Instead apply ratelimiting based on the scheduled send time. # See https://github.com/element-hq/synapse/issues/18021 await self._request_ratelimiter.ratelimit(requester) + max_delay = self._config.server.max_event_delay_ms + if ( + max_delay is None + or max_delay <= 0 + or (limit := self._config.server.max_delayed_events_per_user) <= 0 + ): + raise SynapseError( + HTTPStatus.BAD_REQUEST, + "Sending delayed events has been disallowed", + ) + if delay > max_delay: + raise SynapseError( + HTTPStatus.BAD_REQUEST, + "The requested delay exceeds the allowed maximum", + Codes.INVALID_PARAM, + ) + self._event_creation_handler.validator.validate_builder( self._event_creation_handler.event_builder_factory.for_room_version( await self._store.get_room_version(room_id), @@ -385,7 +405,7 @@ async def add( origin_server_ts=origin_server_ts, content=content, delay=delay, - limit=self._config.server.max_delayed_events_per_user, + limit=limit, sticky_duration_ms=sticky_duration_ms, ) diff --git a/synapse/rest/client/room.py b/synapse/rest/client/room.py index 5527f257f0e..51793cd338f 100644 --- a/synapse/rest/client/room.py +++ b/synapse/rest/client/room.py @@ -215,7 +215,6 @@ def __init__(self, hs: "HomeServer"): self.auth = hs.get_auth() self.clock = hs.get_clock() self._event_serializer = hs.get_event_client_serializer() - self._max_event_delay_ms = hs.config.server.max_event_delay_ms self._spam_checker_module_callbacks = hs.get_module_api_callbacks().spam_checker self._msc4354_enabled = hs.config.experimental.msc4354_enabled @@ -343,7 +342,7 @@ async def on_PUT( if self._msc4354_enabled: sticky_duration_ms = parse_integer(request, StickyEvent.QUERY_PARAM_NAME) - delay = _parse_request_delay(request, self._max_event_delay_ms) + delay = _parse_request_delay(request) if delay is not None: delay_id = await self.delayed_events_handler.add( requester, @@ -416,7 +415,6 @@ def __init__(self, hs: "HomeServer"): self.event_creation_handler = hs.get_event_creation_handler() self.delayed_events_handler = hs.get_delayed_events_handler() self.auth = hs.get_auth() - self._max_event_delay_ms = hs.config.server.max_event_delay_ms self._msc4354_enabled = hs.config.experimental.msc4354_enabled def register(self, http_server: HttpServer) -> None: @@ -442,7 +440,7 @@ async def _do( if self._msc4354_enabled: sticky_duration_ms = parse_integer(request, StickyEvent.QUERY_PARAM_NAME) - delay = _parse_request_delay(request, self._max_event_delay_ms) + delay = _parse_request_delay(request) if delay is not None: delay_id = await self.delayed_events_handler.add( requester, @@ -515,39 +513,19 @@ async def on_PUT( ) -def _parse_request_delay( - request: SynapseRequest, - max_delay: int | None, -) -> int | None: +def _parse_request_delay(request: SynapseRequest) -> int | None: """Parses from the request string the delay parameter for delayed event requests, and checks it for correctness. Args: request: the twisted HTTP request. - max_delay: the maximum allowed value of the delay parameter, - or None if no delay parameter is allowed. Returns: The value of the requested delay, or None if it was absent. Raises: - SynapseError: if the delay parameter is present and forbidden, - or if it exceeds the maximum allowed value. + SynapseError: if the delay parameter is present and invalid. """ - delay = parse_integer(request, "org.matrix.msc4140.delay") - if delay is None: - return None - if max_delay is None: - raise SynapseError( - HTTPStatus.BAD_REQUEST, - "Sending delayed events has been disallowed", - ) - if delay > max_delay: - raise SynapseError( - HTTPStatus.BAD_REQUEST, - "The requested delay exceeds the allowed maximum.", - Codes.INVALID_PARAM, - ) - return delay + return parse_integer(request, "org.matrix.msc4140.delay") # TODO: Needs unit testing for room ID + alias joins diff --git a/synapse/storage/databases/main/delayed_events.py b/synapse/storage/databases/main/delayed_events.py index 1315afbc47b..c7d4508617f 100644 --- a/synapse/storage/databases/main/delayed_events.py +++ b/synapse/storage/databases/main/delayed_events.py @@ -139,11 +139,6 @@ async def add_delayed_event( how many delayed events they may have scheduled at once. SynapseError: if the user is not allowed to schedule any delayed events. """ - if limit <= 0: - raise SynapseError( - HTTPStatus.BAD_REQUEST, - "Sending delayed events has been disallowed", - ) delay_id = _generate_delay_id() send_ts = Timestamp(creation_ts + delay) From 24a543a4bd1e61259c0552aff77838ea5a87de56 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Thu, 18 Jun 2026 08:46:55 -0400 Subject: [PATCH 33/46] Update imports --- synapse/storage/databases/main/delayed_events.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/synapse/storage/databases/main/delayed_events.py b/synapse/storage/databases/main/delayed_events.py index c7d4508617f..d4c1853d341 100644 --- a/synapse/storage/databases/main/delayed_events.py +++ b/synapse/storage/databases/main/delayed_events.py @@ -13,12 +13,11 @@ # import logging -from http import HTTPStatus from typing import TYPE_CHECKING, NewType import attr -from synapse.api.errors import LimitExceededError, NotFoundError, SynapseError +from synapse.api.errors import LimitExceededError, NotFoundError from synapse.storage._base import SQLBaseStore, db_to_json from synapse.storage.database import ( DatabasePool, From b6e61ef8469a16df83d45d5727feda82f65d376c Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Thu, 18 Jun 2026 16:41:09 -0400 Subject: [PATCH 34/46] Run entire user limit test with ratelimit disabled as opposed to running part of the test before lifting ratelimits & part of it after, for the sake of simplifying the test --- tests/rest/client/test_rooms.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index 22f3a9be0df..864fc87da85 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -2580,6 +2580,11 @@ def test_delayed_event_user_limit_exceeded(self) -> None: channel = self.make_request(*args) self.assertEqual(HTTPStatus.OK, channel.code, channel.result) + # Confirm that lifting temporal ratelimits doesn't lift this storage-based limit + self.get_success( + self.hs.get_datastores().main.set_ratelimit_for_user(self.user_id, 0, 0) + ) + channel = self.make_request(*args) self.assertEqual(HTTPStatus.TOO_MANY_REQUESTS, channel.code, channel.result) self.assertEqual( @@ -2592,15 +2597,6 @@ def test_delayed_event_user_limit_exceeded(self) -> None: retry_after_ms = int(retry_after_header[0]) assert retry_after_ms > 0 - # Confirm that lifing temporal ratelimits doesn't lift this storage-based limit - self.get_success( - self.hs.get_datastores().main.set_ratelimit_for_user(self.user_id, 0, 0) - ) - channel = self.make_request(*args) - self.assertEqual(HTTPStatus.TOO_MANY_REQUESTS, channel.code, channel.result) - self.assertIn("retry_after_ms", channel.json_body) - assert channel.headers.getRawHeaders("Retry-After") - self.reactor.advance(retry_after_ms) channel = self.make_request(*args) self.assertEqual(HTTPStatus.OK, channel.code, channel.result) From 22782723c30fca715a2c47373f6dfd715fd8c6f1 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Thu, 18 Jun 2026 16:47:19 -0400 Subject: [PATCH 35/46] Add test comment to explain purpose of Retry-After --- tests/rest/client/test_rooms.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index 864fc87da85..7232ed2620a 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -2592,6 +2592,9 @@ def test_delayed_event_user_limit_exceeded(self) -> None: channel.json_body["errcode"], channel.json_body, ) + # Confirm that the response includes the time remaining until the next of the user's + # delayed events to be sent, at which point another delayed event may be scheduled + # without exceeding the limit retry_after_header = channel.headers.getRawHeaders("Retry-After") assert retry_after_header retry_after_ms = int(retry_after_header[0]) From d805c4d81b704867d13e29edc08c816d794df163 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Thu, 18 Jun 2026 17:17:07 -0400 Subject: [PATCH 36/46] Test for Retry-After having only a single value --- tests/rest/client/test_rooms.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index 7232ed2620a..b305b0bc78f 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -2595,10 +2595,12 @@ def test_delayed_event_user_limit_exceeded(self) -> None: # Confirm that the response includes the time remaining until the next of the user's # delayed events to be sent, at which point another delayed event may be scheduled # without exceeding the limit - retry_after_header = channel.headers.getRawHeaders("Retry-After") - assert retry_after_header - retry_after_ms = int(retry_after_header[0]) + retry_after_headers = channel.headers.getRawHeaders("Retry-After") + assert retry_after_headers + retry_after_ms = int(retry_after_headers[0]) assert retry_after_ms > 0 + # Confirm that there is only a single value to the Retry-After header, as per RFC9110 + self.assertEqual(1, len(retry_after_headers)) self.reactor.advance(retry_after_ms) channel = self.make_request(*args) From 5390153043356aef9a15d761adce73ae19bbf10f Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Thu, 18 Jun 2026 17:20:49 -0400 Subject: [PATCH 37/46] Use TestCase assert for a non-narrowing assertion --- tests/rest/client/test_rooms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index b305b0bc78f..244e9e83faa 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -2598,7 +2598,7 @@ def test_delayed_event_user_limit_exceeded(self) -> None: retry_after_headers = channel.headers.getRawHeaders("Retry-After") assert retry_after_headers retry_after_ms = int(retry_after_headers[0]) - assert retry_after_ms > 0 + self.assertGreater(retry_after_ms, 0) # Confirm that there is only a single value to the Retry-After header, as per RFC9110 self.assertEqual(1, len(retry_after_headers)) From a3d0def352fe47416daf4626d9f5baa5b87123ac Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 26 Jun 2026 14:29:55 -0400 Subject: [PATCH 38/46] Rename capability field to `max_delay_ms` as per https://github.com/matrix-org/matrix-spec-proposals/pull/4140/changes/fd27ef00994bad22b82fd7903b85494d1739a264 --- synapse/rest/client/capabilities.py | 2 +- tests/rest/client/test_capabilities.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/synapse/rest/client/capabilities.py b/synapse/rest/client/capabilities.py index 8c00ecbc901..450a6e35814 100644 --- a/synapse/rest/client/capabilities.py +++ b/synapse/rest/client/capabilities.py @@ -110,7 +110,7 @@ async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]: ]["m.profile_fields"] response["capabilities"]["org.matrix.msc4140.delayed_events"] = { - "max_delay": self.config.server.max_event_delay_ms or 0, + "max_delay_ms": self.config.server.max_event_delay_ms or 0, "max_scheduled": self.config.server.max_delayed_events_per_user, } diff --git a/tests/rest/client/test_capabilities.py b/tests/rest/client/test_capabilities.py index 709eac3fe11..e8099fa4a2b 100644 --- a/tests/rest/client/test_capabilities.py +++ b/tests/rest/client/test_capabilities.py @@ -211,7 +211,7 @@ def test_get_delayed_events_capabilities_default_config_msc4140(self) -> None: self.assertEqual(channel.code, HTTPStatus.OK) self.assertEqual( - capabilities["org.matrix.msc4140.delayed_events"]["max_delay"], 0 + capabilities["org.matrix.msc4140.delayed_events"]["max_delay_ms"], 0 ) self.assertEqual( capabilities["org.matrix.msc4140.delayed_events"]["max_scheduled"], 100 @@ -233,7 +233,7 @@ def test_get_delayed_events_capabilities_custom_config_msc4140(self) -> None: self.assertEqual(channel.code, HTTPStatus.OK) self.assertEqual( - capabilities["org.matrix.msc4140.delayed_events"]["max_delay"], 86400000 + capabilities["org.matrix.msc4140.delayed_events"]["max_delay_ms"], 86400000 ) self.assertEqual( capabilities["org.matrix.msc4140.delayed_events"]["max_scheduled"], 50 From 45b6fc9d8cc5c51851706cdac3c5a04f0a78cba5 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 26 Jun 2026 16:12:06 -0400 Subject: [PATCH 39/46] In test, lift ratelimit before making any request --- tests/rest/client/test_rooms.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index 244e9e83faa..ffdd234cf3c 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -2568,11 +2568,15 @@ def test_delayed_event_exceeds_max_delay(self) -> None: ) def test_delayed_event_user_limit_exceeded(self) -> None: """Test that users cannot have more delayed events scheduled at once than allowed.""" - send_after_ms = 15000 + # Confirm that lifting temporal ratelimits doesn't lift this storage-based limit + self.get_success( + self.hs.get_datastores().main.set_ratelimit_for_user(self.user_id, 0, 0) + ) + args = ( "POST", ( - f"rooms/%s/send/m.room.message?org.matrix.msc4140.delay={send_after_ms}" + "rooms/%s/send/m.room.message?org.matrix.msc4140.delay=15000" % self.room_id ).encode("ascii"), {"body": "test", "msgtype": "m.text"}, @@ -2580,11 +2584,6 @@ def test_delayed_event_user_limit_exceeded(self) -> None: channel = self.make_request(*args) self.assertEqual(HTTPStatus.OK, channel.code, channel.result) - # Confirm that lifting temporal ratelimits doesn't lift this storage-based limit - self.get_success( - self.hs.get_datastores().main.set_ratelimit_for_user(self.user_id, 0, 0) - ) - channel = self.make_request(*args) self.assertEqual(HTTPStatus.TOO_MANY_REQUESTS, channel.code, channel.result) self.assertEqual( From da301326acfb1b3591195b79876c0f3e0613a5c9 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 26 Jun 2026 14:55:42 -0400 Subject: [PATCH 40/46] Test that is_processed events count against limit --- .../storage/databases/main/delayed_events.py | 3 +- tests/rest/client/test_rooms.py | 54 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/synapse/storage/databases/main/delayed_events.py b/synapse/storage/databases/main/delayed_events.py index d4c1853d341..26afcc45001 100644 --- a/synapse/storage/databases/main/delayed_events.py +++ b/synapse/storage/databases/main/delayed_events.py @@ -167,9 +167,10 @@ def add_delayed_event_txn(txn: LoggingTransaction) -> Timestamp: ) row = txn.fetchone() assert row + retry_after_ms = int(row[0]) - creation_ts err = LimitExceededError( limiter_name="add_delayed_event", - retry_after_ms=row[0] - creation_ts, + retry_after_ms=retry_after_ms if retry_after_ms > 0 else None, ) err.msg = "The maximum number of delayed events has been reached." raise err diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index ffdd234cf3c..76b2ea9382e 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -61,6 +61,7 @@ from synapse.server import HomeServer from synapse.types import JsonDict, JsonMapping, RoomAlias, UserID, create_requester from synapse.util.clock import Clock +from synapse.util.duration import Duration from synapse.util.stringutils import random_string from tests import unittest @@ -2605,6 +2606,59 @@ def test_delayed_event_user_limit_exceeded(self) -> None: channel = self.make_request(*args) self.assertEqual(HTTPStatus.OK, channel.code, channel.result) + @unittest.override_config( + { + "max_event_delay_duration": "24h", + "experimental_features": { + "msc4140_max_delayed_events_per_user": 1, + }, + } + ) + def test_delayed_event_processed_user_limit_exceeded(self) -> None: + """ + Test that delayed events in the midst of being sent still count towards the limit of + how many delayed events a user may have scheduled at once. + """ + send_after_ms = 1000 + args = ( + "POST", + ( + f"rooms/%s/send/m.room.message?org.matrix.msc4140.delay={send_after_ms}" + % self.room_id + ).encode("ascii"), + {"body": "test", "msgtype": "m.text"}, + ) + channel = self.make_request(*args) + self.assertEqual(HTTPStatus.OK, channel.code, channel.result) + + # Simulate the server taking a long time to persist delayed events + simulated_send_lag_ms = 5000 + event_creation_handler = self.hs.get_event_creation_handler() + orig_send_fn = event_creation_handler.create_and_send_nonmember_event + + async def slow_send_fn(*args: Any, **kwargs: Any) -> Any: + await self.clock.sleep(Duration(milliseconds=simulated_send_lag_ms)) + return await orig_send_fn(*args, **kwargs) + + with patch.object(event_creation_handler, orig_send_fn.__name__, slow_send_fn): + self.reactor.advance(send_after_ms / 1000) + channel = self.make_request(*args) + self.assertEqual(HTTPStatus.TOO_MANY_REQUESTS, channel.code, channel.result) + self.assertEqual( + Codes.LIMIT_EXCEEDED, + channel.json_body["errcode"], + channel.json_body, + ) + # Confirm that the response lacks a Retry-After header, because the reason for this limit + # is the server taking an indeterminitely long time to process a delayed event, and the + # server doesn't know how much longer the client should wait before sending more requests + retry_after_headers = channel.headers.getRawHeaders("Retry-After") + assert not retry_after_headers + + self.reactor.advance(simulated_send_lag_ms / 1000) + channel = self.make_request(*args) + self.assertEqual(HTTPStatus.OK, channel.code, channel.result) + @unittest.override_config( { "max_event_delay_duration": "24h", From bcd5b61cfdb17acbd8d1e2c2c0ecb4f6c46b2772 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 26 Jun 2026 16:13:45 -0400 Subject: [PATCH 41/46] In test, rename time values to proper unit Retry-After returns a value in seconds, not milliseconds --- tests/rest/client/test_rooms.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index 76b2ea9382e..6ed90302423 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -2597,12 +2597,12 @@ def test_delayed_event_user_limit_exceeded(self) -> None: # without exceeding the limit retry_after_headers = channel.headers.getRawHeaders("Retry-After") assert retry_after_headers - retry_after_ms = int(retry_after_headers[0]) - self.assertGreater(retry_after_ms, 0) + retry_after_sec = int(retry_after_headers[0]) + self.assertGreater(retry_after_sec, 0) # Confirm that there is only a single value to the Retry-After header, as per RFC9110 self.assertEqual(1, len(retry_after_headers)) - self.reactor.advance(retry_after_ms) + self.reactor.advance(retry_after_sec) channel = self.make_request(*args) self.assertEqual(HTTPStatus.OK, channel.code, channel.result) @@ -2700,10 +2700,10 @@ def test_delayed_event_custom_user_limit_exceeded(self) -> None: ) retry_after_header = channel.headers.getRawHeaders("Retry-After") assert retry_after_header - retry_after_ms = int(retry_after_header[0]) - assert retry_after_ms > 0 + retry_after_sec = int(retry_after_header[0]) + assert retry_after_sec > 0 - self.reactor.advance(retry_after_ms) + self.reactor.advance(retry_after_sec) channel = self.make_request(*args) self.assertEqual(HTTPStatus.OK, channel.code, channel.result) From 80b47aa6011a8141ccc97a800595fb102382e825 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 26 Jun 2026 16:17:29 -0400 Subject: [PATCH 42/46] Enforce & document positive limit in storage fn Disallow calling the storage function with a non-positive `limit`. Better communicate this restriction by raising a ValueError on an invalid `limit`, and add a docstring that mentions the restriction. Also move `limit` to the end of the parameter list, to make it more noticeable (and to allow its docstring to come last & match the ordering in the parameter list). Note that a limit of 0 is still allowed for the handler function. Now only the storage function does not allow it. --- synapse/handlers/delayed_events.py | 2 +- .../storage/databases/main/delayed_events.py | 27 ++++++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/synapse/handlers/delayed_events.py b/synapse/handlers/delayed_events.py index 21430b9f9b0..04ff7b06a9a 100644 --- a/synapse/handlers/delayed_events.py +++ b/synapse/handlers/delayed_events.py @@ -405,8 +405,8 @@ async def add( origin_server_ts=origin_server_ts, content=content, delay=delay, - limit=limit, sticky_duration_ms=sticky_duration_ms, + limit=limit, ) if self._repl_client is not None: diff --git a/synapse/storage/databases/main/delayed_events.py b/synapse/storage/databases/main/delayed_events.py index 26afcc45001..766a8cecac7 100644 --- a/synapse/storage/databases/main/delayed_events.py +++ b/synapse/storage/databases/main/delayed_events.py @@ -123,21 +123,40 @@ async def add_delayed_event( origin_server_ts: int | None, content: JsonDict, delay: int, - limit: int, sticky_duration_ms: int | None, + limit: int, ) -> tuple[DelayID, Timestamp]: """ Inserts a new delayed event in the DB. + Args: + user_localpart: The localpart of the requester of the delayed event, who will be its owner. + device_id: The device ID of the requester. + creation_ts: The timestamp of when the request to add the delayed event was made. + room_id: The ID of the room where the event should be sent to. + event_type: The type of event to be sent. + state_key: The state key of the event to be sent, or None if it is not a state event. + origin_server_ts: The custom timestamp to send the event with. + If None, the timestamp will be the actual time when the event is sent. + content: The content of the event to be sent. + delay: How long (in milliseconds) to wait before automatically sending the event. + sticky_duration_ms: If an MSC4354 sticky event: the sticky duration (in milliseconds). + The event will be attempted to be reliably delivered to clients and remote servers + during its sticky period. + limit: The maximum number of delayed events the DB may store for the given requester. + Must be greater than 0. Returns: The generated ID assigned to the added delayed event, and the send time of the next delayed event to be sent, which is either the event just added or one added earlier. Raises: - LimitExceededError: if the user has reached the limit of - how many delayed events they may have scheduled at once. - SynapseError: if the user is not allowed to schedule any delayed events. + LimitExceededError: if the DB has reached the limit of + how many delayed events it may store for the given requester. + ValueError: if the limit is not greater than 0. """ + if limit <= 0: + raise ValueError("limit must be greater than 0") + delay_id = _generate_delay_id() send_ts = Timestamp(creation_ts + delay) From 06a6a9e3aeedcbfc28cfc6e2f20bf20cbc3ab33a Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 26 Jun 2026 16:33:14 -0400 Subject: [PATCH 43/46] Restore comment on Retry-After lookup query --- synapse/storage/databases/main/delayed_events.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/synapse/storage/databases/main/delayed_events.py b/synapse/storage/databases/main/delayed_events.py index 766a8cecac7..47987eec01c 100644 --- a/synapse/storage/databases/main/delayed_events.py +++ b/synapse/storage/databases/main/delayed_events.py @@ -168,6 +168,9 @@ def add_delayed_event_txn(txn: LoggingTransaction) -> Timestamp: retcol="COUNT(*)", ) if num_existing >= limit: + # Cover case of num_existing > limit, which can happen by reducing + # the configured limit after delayed events have already been added + # (or by calling this method with a limit lower than the configured one) # # FIXME: Remove "AS subquery" after dropping support for PostgreSQL <16 txn.execute( From ba17cdaa87db608bb47d8ffbe3969c45a01b98a2 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 26 Jun 2026 16:55:39 -0400 Subject: [PATCH 44/46] In tests, use closures instead of passing `*args` --- tests/rest/client/test_rooms.py | 48 +++++++++++++++++---------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index 6ed90302423..f844af1c2f5 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -2574,7 +2574,7 @@ def test_delayed_event_user_limit_exceeded(self) -> None: self.hs.get_datastores().main.set_ratelimit_for_user(self.user_id, 0, 0) ) - args = ( + make_request = lambda: self.make_request( "POST", ( "rooms/%s/send/m.room.message?org.matrix.msc4140.delay=15000" @@ -2582,10 +2582,10 @@ def test_delayed_event_user_limit_exceeded(self) -> None: ).encode("ascii"), {"body": "test", "msgtype": "m.text"}, ) - channel = self.make_request(*args) + channel = make_request() self.assertEqual(HTTPStatus.OK, channel.code, channel.result) - channel = self.make_request(*args) + channel = make_request() self.assertEqual(HTTPStatus.TOO_MANY_REQUESTS, channel.code, channel.result) self.assertEqual( Codes.LIMIT_EXCEEDED, @@ -2603,7 +2603,7 @@ def test_delayed_event_user_limit_exceeded(self) -> None: self.assertEqual(1, len(retry_after_headers)) self.reactor.advance(retry_after_sec) - channel = self.make_request(*args) + channel = make_request() self.assertEqual(HTTPStatus.OK, channel.code, channel.result) @unittest.override_config( @@ -2620,7 +2620,7 @@ def test_delayed_event_processed_user_limit_exceeded(self) -> None: how many delayed events a user may have scheduled at once. """ send_after_ms = 1000 - args = ( + make_request = lambda: self.make_request( "POST", ( f"rooms/%s/send/m.room.message?org.matrix.msc4140.delay={send_after_ms}" @@ -2628,7 +2628,7 @@ def test_delayed_event_processed_user_limit_exceeded(self) -> None: ).encode("ascii"), {"body": "test", "msgtype": "m.text"}, ) - channel = self.make_request(*args) + channel = make_request() self.assertEqual(HTTPStatus.OK, channel.code, channel.result) # Simulate the server taking a long time to persist delayed events @@ -2642,7 +2642,7 @@ async def slow_send_fn(*args: Any, **kwargs: Any) -> Any: with patch.object(event_creation_handler, orig_send_fn.__name__, slow_send_fn): self.reactor.advance(send_after_ms / 1000) - channel = self.make_request(*args) + channel = make_request() self.assertEqual(HTTPStatus.TOO_MANY_REQUESTS, channel.code, channel.result) self.assertEqual( Codes.LIMIT_EXCEEDED, @@ -2656,7 +2656,7 @@ async def slow_send_fn(*args: Any, **kwargs: Any) -> Any: assert not retry_after_headers self.reactor.advance(simulated_send_lag_ms / 1000) - channel = self.make_request(*args) + channel = make_request() self.assertEqual(HTTPStatus.OK, channel.code, channel.result) @unittest.override_config( @@ -2675,23 +2675,25 @@ def test_delayed_event_custom_user_limit_exceeded(self) -> None: This can be invoked by the server admin lowering the configured limit & restarting the server while a user has fewer scheduled delayed events than the old limit, but more than the new limit. """ + send_after_ms: int + make_request = lambda: self.make_request( + "POST", + ( + f"rooms/%s/send/m.room.message?org.matrix.msc4140.delay={send_after_ms}" + % self.room_id + ).encode("ascii"), + {"body": f"test (send after {send_after_ms})", "msgtype": "m.text"}, + ) + for i in range(3): send_after_ms = 1000 * i - args = ( - "POST", - ( - f"rooms/%s/send/m.room.message?org.matrix.msc4140.delay={send_after_ms}" - % self.room_id - ).encode("ascii"), - {"body": f"test{i}", "msgtype": "m.text"}, - ) - channel = self.make_request(*args) + channel = make_request() self.assertEqual(HTTPStatus.OK, channel.code, channel.result) # Simulate lowering the configured limit & applying it with a server restart self.hs.config.server.max_delayed_events_per_user = 1 - channel = self.make_request(*args) + channel = make_request() self.assertEqual(HTTPStatus.TOO_MANY_REQUESTS, channel.code, channel.result) self.assertEqual( Codes.LIMIT_EXCEEDED, @@ -2704,7 +2706,7 @@ def test_delayed_event_custom_user_limit_exceeded(self) -> None: assert retry_after_sec > 0 self.reactor.advance(retry_after_sec) - channel = self.make_request(*args) + channel = make_request() self.assertEqual(HTTPStatus.OK, channel.code, channel.result) @unittest.override_config({"max_event_delay_duration": "24h"}) @@ -2762,7 +2764,7 @@ def test_add_delayed_event_ratelimit(self) -> None: """ # Test that new delayed events are correctly ratelimited. - args = ( + make_request = lambda: self.make_request( "POST", ( "rooms/%s/send/m.room.message?org.matrix.msc4140.delay=2000" @@ -2770,9 +2772,9 @@ def test_add_delayed_event_ratelimit(self) -> None: ).encode("ascii"), {"body": "test", "msgtype": "m.text"}, ) - channel = self.make_request(*args) + channel = make_request() self.assertEqual(HTTPStatus.OK, channel.code, channel.result) - channel = self.make_request(*args) + channel = make_request() self.assertEqual(HTTPStatus.TOO_MANY_REQUESTS, channel.code, channel.result) # Add the current user to the ratelimit overrides, allowing them no ratelimiting. @@ -2781,7 +2783,7 @@ def test_add_delayed_event_ratelimit(self) -> None: ) # Test that the new delayed events aren't ratelimited anymore. - channel = self.make_request(*args) + channel = make_request() self.assertEqual(HTTPStatus.OK, channel.code, channel.result) From 17d1bfe5ec1281d6b5141db73e5daa158c5c890d Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 26 Jun 2026 17:04:34 -0400 Subject: [PATCH 45/46] Tweak user limit tweak test Test with the limit dropped to 2 rather than 1, so that the required wait won't be for all delayed events to be sent, just for enough of them to be sent to permit scheduling a new one. --- tests/rest/client/test_rooms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index f844af1c2f5..48835671dad 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -2691,7 +2691,7 @@ def test_delayed_event_custom_user_limit_exceeded(self) -> None: self.assertEqual(HTTPStatus.OK, channel.code, channel.result) # Simulate lowering the configured limit & applying it with a server restart - self.hs.config.server.max_delayed_events_per_user = 1 + self.hs.config.server.max_delayed_events_per_user = 2 channel = make_request() self.assertEqual(HTTPStatus.TOO_MANY_REQUESTS, channel.code, channel.result) From 2f0a3026a45be57f25b465dd2d5ea0a20a218c4a Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Tue, 30 Jun 2026 14:00:45 -0400 Subject: [PATCH 46/46] Use M_FORBIDDEN/403 for disallowed/exceeded delay See: - https://github.com/matrix-org/matrix-spec-proposals/pull/4140#discussion_r3483103991 - https://github.com/matrix-org/matrix-spec-proposals/pull/4140#discussion_r3500056654 --- synapse/handlers/delayed_events.py | 7 ++++--- tests/rest/client/test_rooms.py | 18 ++++++++++++++---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/synapse/handlers/delayed_events.py b/synapse/handlers/delayed_events.py index 04ff7b06a9a..b119ca62519 100644 --- a/synapse/handlers/delayed_events.py +++ b/synapse/handlers/delayed_events.py @@ -370,14 +370,15 @@ async def add( or (limit := self._config.server.max_delayed_events_per_user) <= 0 ): raise SynapseError( - HTTPStatus.BAD_REQUEST, + HTTPStatus.FORBIDDEN, "Sending delayed events has been disallowed", + Codes.FORBIDDEN, ) if delay > max_delay: raise SynapseError( - HTTPStatus.BAD_REQUEST, + HTTPStatus.FORBIDDEN, "The requested delay exceeds the allowed maximum", - Codes.INVALID_PARAM, + Codes.FORBIDDEN, ) self._event_creation_handler.validator.validate_builder( diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index 48835671dad..07a5136cc5e 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -2519,7 +2519,12 @@ def test_delayed_event_unsupported_by_default(self) -> None: ).encode("ascii"), {"body": "test", "msgtype": "m.text"}, ) - self.assertEqual(HTTPStatus.BAD_REQUEST, channel.code, channel.result) + self.assertEqual(HTTPStatus.FORBIDDEN, channel.code, channel.result) + self.assertEqual( + Codes.FORBIDDEN, + channel.json_body.get("errcode"), + channel.json_body, + ) @unittest.override_config( { @@ -2539,7 +2544,12 @@ def test_delayed_event_disabled_by_limit(self) -> None: ).encode("ascii"), {"body": "test", "msgtype": "m.text"}, ) - self.assertEqual(HTTPStatus.BAD_REQUEST, channel.code, channel.result) + self.assertEqual(HTTPStatus.FORBIDDEN, channel.code, channel.result) + self.assertEqual( + Codes.FORBIDDEN, + channel.json_body.get("errcode"), + channel.json_body, + ) @unittest.override_config({"max_event_delay_duration": "1000"}) def test_delayed_event_exceeds_max_delay(self) -> None: @@ -2552,9 +2562,9 @@ def test_delayed_event_exceeds_max_delay(self) -> None: ).encode("ascii"), {"body": "test", "msgtype": "m.text"}, ) - self.assertEqual(HTTPStatus.BAD_REQUEST, channel.code, channel.result) + self.assertEqual(HTTPStatus.FORBIDDEN, channel.code, channel.result) self.assertEqual( - Codes.INVALID_PARAM, + Codes.FORBIDDEN, channel.json_body.get("errcode"), channel.json_body, )