Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
c2d1039
MSC4140: impose limit of scheduled delayed events
AndrewFerr Mar 9, 2026
c70c428
MSC4140: update error codes to match latest MSC
AndrewFerr Mar 9, 2026
3b51b48
Add changelogs
AndrewFerr Mar 9, 2026
ac2bed9
Assert against variable message, not literal
AndrewFerr Mar 12, 2026
e02f554
Put MSC4140 config near other one & allow stable
AndrewFerr Mar 12, 2026
2e01d7f
In test, assign default error message to variable
AndrewFerr Mar 13, 2026
79a0218
Explain reason for location of experimental config
AndrewFerr Mar 13, 2026
a614ebb
Leave new config as experimental only
AndrewFerr Mar 13, 2026
448bf17
Set Retry-After on delayed event limit error
AndrewFerr Mar 16, 2026
43e14e9
Enforce max delayed event config to be positive
AndrewFerr Mar 16, 2026
f69ddc1
Update copyright dates
AndrewFerr Mar 16, 2026
437a034
Revert "Update copyright dates"
AndrewFerr Jun 2, 2026
5dfc8b8
Merge with 'develop'
AndrewFerr Jun 5, 2026
1b19c13
Use capabilities to convey delayed event limits
AndrewFerr Jun 7, 2026
267a7e5
Allow delayed event limit to be set to 0
AndrewFerr Jun 7, 2026
df09f8d
Rename variable to better indicate it as a time
AndrewFerr Jun 7, 2026
3804db0
Elaborate comment on make_request time step
AndrewFerr Jun 7, 2026
184b32d
Remove unnecessary wait in test
AndrewFerr Jun 7, 2026
c19df5d
Clarify config limits in errors & test comments
AndrewFerr Jun 8, 2026
377cac4
Lint
AndrewFerr Jun 8, 2026
0b0aab1
Document special-case error for limit <= 0
AndrewFerr Jun 12, 2026
d62f440
Cover case of limit << num existing delayed events
AndrewFerr Jun 12, 2026
4a9464c
Clarify test on ratelimit override
AndrewFerr Jun 12, 2026
79a6f31
Simplify delayed event limit test
AndrewFerr Jun 12, 2026
34c5ce9
Use consistent keyvalues when applying limit
AndrewFerr Jun 12, 2026
2eea7f5
Use "err" instead of "e"
AndrewFerr Jun 12, 2026
87ce5f3
Fixups
AndrewFerr Jun 12, 2026
e1a0ba4
Test limit << num existing delayed events
AndrewFerr Jun 12, 2026
1a2895b
Add alias to sub-SELECT, needed for PostgreSQL <16
AndrewFerr Jun 12, 2026
7bc51d7
Apply suggestions from code review
AndrewFerr Jun 17, 2026
56439ef
Improve test comments
AndrewFerr Jun 17, 2026
d2e84b7
Fix typo in test docstring
AndrewFerr Jun 17, 2026
af16899
Move all delayed event limit processing in handler
AndrewFerr Jun 17, 2026
24a543a
Update imports
AndrewFerr Jun 18, 2026
b6e61ef
Run entire user limit test with ratelimit disabled
AndrewFerr Jun 18, 2026
2278272
Add test comment to explain purpose of Retry-After
AndrewFerr Jun 18, 2026
d805c4d
Test for Retry-After having only a single value
AndrewFerr Jun 18, 2026
5390153
Use TestCase assert for a non-narrowing assertion
AndrewFerr Jun 18, 2026
a3d0def
Rename capability field to `max_delay_ms`
AndrewFerr Jun 26, 2026
45b6fc9
In test, lift ratelimit before making any request
AndrewFerr Jun 26, 2026
da30132
Test that is_processed events count against limit
AndrewFerr Jun 26, 2026
bcd5b61
In test, rename time values to proper unit
AndrewFerr Jun 26, 2026
80b47aa
Enforce & document positive limit in storage fn
AndrewFerr Jun 26, 2026
06a6a9e
Restore comment on Retry-After lookup query
AndrewFerr Jun 26, 2026
ba17cda
In tests, use closures instead of passing `*args`
AndrewFerr Jun 26, 2026
17d1bfe
Tweak user limit tweak test
AndrewFerr Jun 26, 2026
2f0a302
Use M_FORBIDDEN/403 for disallowed/exceeded delay
AndrewFerr Jun 30, 2026
4e0f0bc
Apply suggestions from code review
AndrewFerr Jul 8, 2026
2b41710
Include values in error msg of too-large delay
AndrewFerr Jul 8, 2026
ce02497
Rename function for parsing delay from request
AndrewFerr Jul 8, 2026
c73d85f
Add schema comment to explain is_processed
AndrewFerr Jul 8, 2026
4a61644
Comment that test doesn't need specific errcode
AndrewFerr Jul 8, 2026
73513bd
Replace ValueError with assert
AndrewFerr Jul 8, 2026
71942b0
Delay Retry-After from response time, not request
AndrewFerr Jul 8, 2026
287eedf
Comment query on num_existing > limit case
AndrewFerr Jul 8, 2026
c186d1d
Fix test coverage of num_existing > limit
AndrewFerr Jul 8, 2026
bf70a71
Test with a Duration for improved readability
AndrewFerr Jul 8, 2026
dce57e5
Use simpler query for num_existing == limit
AndrewFerr Jul 8, 2026
5cd7c10
Reword comments on num_existing > limit case
AndrewFerr Jul 9, 2026
e828384
Replace final send_after_ms in test with Duration
AndrewFerr Jul 9, 2026
43ebc76
Use Duration, not int, for delay & max delay
AndrewFerr Jul 9, 2026
5208473
Revert query split, and elaborate comment
AndrewFerr Jul 9, 2026
0859de0
Merge with 'develop' & fix conflict in /versions
AndrewFerr Jul 10, 2026
c33d443
Check msc4140_enabled in handler
AndrewFerr Jul 10, 2026
b384bb8
Rename limit-checking tests
AndrewFerr Jul 10, 2026
0cab550
Reword comment in limit exceeded test
AndrewFerr Jul 10, 2026
c5b8248
Test error shape, not namespace of errcode
AndrewFerr Jul 10, 2026
4c597e4
Merge with 'develop'
AndrewFerr Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/19539.bugfix
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions changelog.d/19539.feature
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 18 additions & 1 deletion synapse/config/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -914,10 +914,27 @@ 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(
"'max_event_delay_duration' must be a positive value if set",
("max_event_delay_duration",),
)
Comment on lines +955 to +958

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explain more why and what do do.

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.

-- 43e14e9

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

267a7e5 rewords the error message to be a bit more descriptive, and to follow the same format used by similar errors in this module.

@MadLittleMods MadLittleMods Jun 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per the original intentions, I was more looking for something like this:

Suggested change
raise ConfigError(
"Expected a positive value", ("max_event_delay_duration",)
)
raise ConfigError(
"Expected a non-zero, positive value for the delay duration. To disable delayed events, leave `max_event_delay_duration` unspecified.", ("max_event_delay_duration",)
)

It seems like ideally, we would have had this kind of structure for delayed event config:

delayed_events:
  enabled: true
  max_event_delay_duration: 24h
  max_delayed_events_per_user: 100

If we're going with 0 as a valid value to disable delayed events, I guess the updated error message works ⏩

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That new config does look better, though I'd prefer using a dedicated PR to change it, given that max_event_delay_duration has been around for a while now & moving it would be a breaking change.

I'd also prefer to keep config suggestions in the documentation instead of error messages, to reduce churn on code changes while the MSC is still unstable.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like msc4140_enabled was added. Are we interested in getting the config shape correct from the beginning?

Generally, we try not to break peoples homeserver config. And changing this later means even more complication for the backwards compatibility.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

msc4140_enabled isn't a real config key. It's just a computed value to make it easier to have /versions report whether delayed events are enabled via the other config keys, done since 0859de0. (It is also used in c33d443.)

Which is to say, the config shape has not changed, nor do I intend to change it.

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("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(
"'msc4140_max_delayed_events_per_user' must be a non-negative integer",
("experimental", "msc4140_max_delayed_events_per_user"),
)

def has_tls_listener(self) -> bool:
return any(listener.is_tls() for listener in self.listeners)

Expand Down
1 change: 1 addition & 0 deletions synapse/handlers/delayed_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,7 @@ async def add(
origin_server_ts=origin_server_ts,
content=content,
delay=delay,
limit=self._config.server.max_delayed_events_per_user,
sticky_duration_ms=sticky_duration_ms,
)

Expand Down
5 changes: 5 additions & 0 deletions synapse/rest/client/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 2 additions & 10 deletions synapse/rest/client/room.py
Original file line number Diff line number Diff line change
Expand Up @@ -539,21 +539,13 @@ def _parse_request_delay(
if max_delay is None:
raise SynapseError(
HTTPStatus.BAD_REQUEST,
"Delayed events are not supported on this server",
Codes.UNKNOWN,
{
"org.matrix.msc4140.errcode": "M_MAX_DELAY_UNSUPPORTED",
},
"Sending delayed events has been disallowed",
)
if delay > max_delay:
raise SynapseError(
HTTPStatus.BAD_REQUEST,
"The requested delay exceeds the allowed maximum.",
Codes.UNKNOWN,
{
"org.matrix.msc4140.errcode": "M_MAX_DELAY_EXCEEDED",
"org.matrix.msc4140.max_delay": max_delay,
},
Codes.INVALID_PARAM,
)
return delay

Expand Down
37 changes: 36 additions & 1 deletion synapse/storage/databases/main/delayed_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@
#

import logging
from http import HTTPStatus
from typing import TYPE_CHECKING, NewType

import attr

from synapse.api.errors import NotFoundError
from synapse.api.errors import LimitExceededError, NotFoundError, SynapseError
from synapse.storage._base import SQLBaseStore, db_to_json
from synapse.storage.database import (
DatabasePool,
Expand Down Expand Up @@ -123,6 +124,7 @@ async def add_delayed_event(
origin_server_ts: int | None,
content: JsonDict,
delay: int,
Comment thread
MadLittleMods marked this conversation as resolved.
Outdated
limit: int,
sticky_duration_ms: int | None,
) -> tuple[DelayID, Timestamp]:
"""
Expand All @@ -131,11 +133,44 @@ 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.
"""
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)

def add_delayed_event_txn(txn: LoggingTransaction) -> Timestamp:
num_existing: int = self.db_pool.simple_select_one_onecol_txn(
txn,
table="delayed_events",
keyvalues={"user_localpart": user_localpart},
retcol="COUNT(*)",
)
Comment thread
AndrewFerr marked this conversation as resolved.
Comment thread
MadLittleMods marked this conversation as resolved.
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)",
)
Comment thread
MadLittleMods marked this conversation as resolved.
Outdated
e = LimitExceededError(
limiter_name="add_delayed_event",
retry_after_ms=next_send_ts - creation_ts,
Comment thread
MadLittleMods marked this conversation as resolved.
Outdated
)
e.msg = "The maximum number of delayed events has been reached."
raise e
Comment thread
AndrewFerr marked this conversation as resolved.
Outdated

self.db_pool.simple_insert_txn(
txn,
table="delayed_events",
Expand Down
52 changes: 52 additions & 0 deletions tests/config/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@
#
#


from typing import Any

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

Expand Down Expand Up @@ -189,6 +193,54 @@ 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:
Comment thread
AndrewFerr marked this conversation as resolved.
"""
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}

_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_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}
}

for allowed_value in (0, 1):
_read_config(generate_config(allowed_value))

for disallowed_value in (-1, 0.5):
with self.assertRaises(ConfigError):
_read_config(generate_config(disallowed_value))


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:
Expand Down
36 changes: 36 additions & 0 deletions tests/rest/client/test_capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
MadLittleMods marked this conversation as resolved.
Outdated
)
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."""
Expand Down
89 changes: 82 additions & 7 deletions tests/rest/client/test_rooms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2503,7 +2504,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,
)
Comment thread
MadLittleMods marked this conversation as resolved.
Outdated

def test_delayed_event_unsupported_by_default(self) -> None:
"""Test that sending a delayed event is unsupported with the default config."""
Expand All @@ -2516,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(
"M_MAX_DELAY_UNSUPPORTED",
channel.json_body.get("org.matrix.msc4140.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:
Expand All @@ -2535,10 +2554,66 @@ 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"),
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": 1,
},
}
)
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
args = (
"POST",
(
f"rooms/%s/send/m.room.message?org.matrix.msc4140.delay={send_after_ms}"
% self.room_id
).encode("ascii"),
Comment on lines +2591 to +2594

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this work?

Suggested change
(
"rooms/%s/send/m.room.message?org.matrix.msc4140.delay=15000"
% self.room_id
).encode("ascii"),
f"rooms/{self.room_id}/send/m.room.message?org.matrix.msc4140.delay=15000",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it does. The only reason I used %-formatting here was solely for consistency with existing test code.

It's also indeed fine to remove .encode("ascii"), as that ends up getting done by make_request anyways.

Though to reduce the size of this PR, I'd rather do this cleanup in a followup dedicated PR, which could even apply it for other tests.

{"body": "test", "msgtype": "m.text"},
)
channel = self.make_request(*args)
self.assertEqual(HTTPStatus.OK, channel.code, channel.result)

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,
)
step_ms = 100 # This is the amount of time advanced by a call to make_request
Comment thread
MadLittleMods marked this conversation as resolved.
Outdated
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
Comment thread
MadLittleMods marked this conversation as resolved.
Outdated
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)
)
Comment thread
MadLittleMods marked this conversation as resolved.
Outdated
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:
Expand Down
Loading