Skip to content
This repository was archived by the owner on Apr 26, 2024. It is now read-only.

Commit 9d8e380

Browse files
authored
Respect the @cancellable flag for RestServlets and BaseFederationServlets (#12699)
Both `RestServlet`s and `BaseFederationServlet`s register their handlers with `HttpServer.register_paths` / `JsonResource.register_paths`. Update `JsonResource` to respect the `@cancellable` flag on handlers registered in this way. Although `ReplicationEndpoint` also registers itself using `register_paths`, it does not pass the handler method that would have the `@cancellable` flag directly, and so needs separate handling. Signed-off-by: Sean Quah <[email protected]>
1 parent dffecad commit 9d8e380

File tree

6 files changed

+191
-2
lines changed

6 files changed

+191
-2
lines changed

changelog.d/12699.misc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Respect the `@cancellable` flag for `RestServlet`s and `BaseFederationServlet`s.

synapse/http/server.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,9 @@ def register_paths(
314314
If the regex contains groups these gets passed to the callback via
315315
an unpacked tuple.
316316
317+
The callback may be marked with the `@cancellable` decorator, which will
318+
cause request processing to be cancelled when clients disconnect early.
319+
317320
Args:
318321
method: The HTTP method to listen to.
319322
path_patterns: The regex used to match requests.
@@ -544,6 +547,8 @@ def _get_handler_for_request(
544547
async def _async_render(self, request: SynapseRequest) -> Tuple[int, Any]:
545548
callback, servlet_classname, group_dict = self._get_handler_for_request(request)
546549

550+
request.is_render_cancellable = is_method_cancellable(callback)
551+
547552
# Make sure we have an appropriate name for this handler in prometheus
548553
# (rather than the default of JsonResource).
549554
request.request_metrics.name = servlet_classname
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Copyright 2022 The Matrix.org Foundation C.I.C.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Copyright 2022 The Matrix.org Foundation C.I.C.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from http import HTTPStatus
16+
from typing import Dict, List, Tuple
17+
18+
from synapse.api.errors import Codes
19+
from synapse.federation.transport.server import BaseFederationServlet
20+
from synapse.federation.transport.server._base import Authenticator
21+
from synapse.http.server import JsonResource, cancellable
22+
from synapse.server import HomeServer
23+
from synapse.types import JsonDict
24+
from synapse.util.ratelimitutils import FederationRateLimiter
25+
26+
from tests import unittest
27+
from tests.http.server._base import EndpointCancellationTestHelperMixin
28+
29+
30+
class CancellableFederationServlet(BaseFederationServlet):
31+
PATH = "/sleep"
32+
33+
def __init__(
34+
self,
35+
hs: HomeServer,
36+
authenticator: Authenticator,
37+
ratelimiter: FederationRateLimiter,
38+
server_name: str,
39+
):
40+
super().__init__(hs, authenticator, ratelimiter, server_name)
41+
self.clock = hs.get_clock()
42+
43+
@cancellable
44+
async def on_GET(
45+
self, origin: str, content: None, query: Dict[bytes, List[bytes]]
46+
) -> Tuple[int, JsonDict]:
47+
await self.clock.sleep(1.0)
48+
return HTTPStatus.OK, {"result": True}
49+
50+
async def on_POST(
51+
self, origin: str, content: JsonDict, query: Dict[bytes, List[bytes]]
52+
) -> Tuple[int, JsonDict]:
53+
await self.clock.sleep(1.0)
54+
return HTTPStatus.OK, {"result": True}
55+
56+
57+
class BaseFederationServletCancellationTests(
58+
unittest.FederatingHomeserverTestCase, EndpointCancellationTestHelperMixin
59+
):
60+
"""Tests for `BaseFederationServlet` cancellation."""
61+
62+
path = f"{CancellableFederationServlet.PREFIX}{CancellableFederationServlet.PATH}"
63+
64+
def create_test_resource(self):
65+
"""Overrides `HomeserverTestCase.create_test_resource`."""
66+
resource = JsonResource(self.hs)
67+
68+
CancellableFederationServlet(
69+
hs=self.hs,
70+
authenticator=Authenticator(self.hs),
71+
ratelimiter=self.hs.get_federation_ratelimiter(),
72+
server_name=self.hs.hostname,
73+
).register(resource)
74+
75+
return resource
76+
77+
def test_cancellable_disconnect(self) -> None:
78+
"""Test that handlers with the `@cancellable` flag can be cancelled."""
79+
channel = self.make_signed_federation_request(
80+
"GET", self.path, await_result=False
81+
)
82+
83+
# Advance past all the rate limiting logic. If we disconnect too early, the
84+
# request won't be processed.
85+
self.pump()
86+
87+
self._test_disconnect(
88+
self.reactor,
89+
channel,
90+
expect_cancellation=True,
91+
expected_body={"error": "Request cancelled", "errcode": Codes.UNKNOWN},
92+
)
93+
94+
def test_uncancellable_disconnect(self) -> None:
95+
"""Test that handlers without the `@cancellable` flag cannot be cancelled."""
96+
channel = self.make_signed_federation_request(
97+
"POST",
98+
self.path,
99+
content={},
100+
await_result=False,
101+
)
102+
103+
# Advance past all the rate limiting logic. If we disconnect too early, the
104+
# request won't be processed.
105+
self.pump()
106+
107+
self._test_disconnect(
108+
self.reactor,
109+
channel,
110+
expect_cancellation=False,
111+
expected_body={"result": True},
112+
)

tests/http/test_servlet.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,25 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414
import json
15+
from http import HTTPStatus
1516
from io import BytesIO
17+
from typing import Tuple
1618
from unittest.mock import Mock
1719

18-
from synapse.api.errors import SynapseError
20+
from synapse.api.errors import Codes, SynapseError
21+
from synapse.http.server import cancellable
1922
from synapse.http.servlet import (
23+
RestServlet,
2024
parse_json_object_from_request,
2125
parse_json_value_from_request,
2226
)
27+
from synapse.http.site import SynapseRequest
28+
from synapse.rest.client._base import client_patterns
29+
from synapse.server import HomeServer
30+
from synapse.types import JsonDict
2331

2432
from tests import unittest
33+
from tests.http.server._base import EndpointCancellationTestHelperMixin
2534

2635

2736
def make_request(content):
@@ -76,3 +85,52 @@ def test_parse_json_object(self):
7685
# Test not an object
7786
with self.assertRaises(SynapseError):
7887
parse_json_object_from_request(make_request(b'["foo"]'))
88+
89+
90+
class CancellableRestServlet(RestServlet):
91+
"""A `RestServlet` with a mix of cancellable and uncancellable handlers."""
92+
93+
PATTERNS = client_patterns("/sleep$")
94+
95+
def __init__(self, hs: HomeServer):
96+
super().__init__()
97+
self.clock = hs.get_clock()
98+
99+
@cancellable
100+
async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
101+
await self.clock.sleep(1.0)
102+
return HTTPStatus.OK, {"result": True}
103+
104+
async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
105+
await self.clock.sleep(1.0)
106+
return HTTPStatus.OK, {"result": True}
107+
108+
109+
class TestRestServletCancellation(
110+
unittest.HomeserverTestCase, EndpointCancellationTestHelperMixin
111+
):
112+
"""Tests for `RestServlet` cancellation."""
113+
114+
servlets = [
115+
lambda hs, http_server: CancellableRestServlet(hs).register(http_server)
116+
]
117+
118+
def test_cancellable_disconnect(self) -> None:
119+
"""Test that handlers with the `@cancellable` flag can be cancelled."""
120+
channel = self.make_request("GET", "/sleep", await_result=False)
121+
self._test_disconnect(
122+
self.reactor,
123+
channel,
124+
expect_cancellation=True,
125+
expected_body={"error": "Request cancelled", "errcode": Codes.UNKNOWN},
126+
)
127+
128+
def test_uncancellable_disconnect(self) -> None:
129+
"""Test that handlers without the `@cancellable` flag cannot be cancelled."""
130+
channel = self.make_request("POST", "/sleep", await_result=False)
131+
self._test_disconnect(
132+
self.reactor,
133+
channel,
134+
expect_cancellation=False,
135+
expected_body={"result": True},
136+
)

tests/unittest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -831,7 +831,7 @@ def make_signed_federation_request(
831831
self.site,
832832
method=method,
833833
path=path,
834-
content=content or "",
834+
content=content if content is not None else "",
835835
shorthand=False,
836836
await_result=await_result,
837837
custom_headers=custom_headers,

0 commit comments

Comments
 (0)