Skip to content

Commit 24c5e46

Browse files
authored
Release completed operations in the legacy graphql-ws handler (#4610)
1 parent 6faab98 commit 24c5e46

3 files changed

Lines changed: 135 additions & 6 deletions

File tree

RELEASE.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
release type: patch
3+
social_messages:
4+
x: >-
5+
{project_name} {version} is out! This release fixes the legacy graphql-ws
6+
handler so completed subscriptions no longer count towards
7+
max_subscriptions_per_connection. 🍓 https://strawberry.rocks/release/{version}
8+
linkedin: >-
9+
{project_name} {version} is out. This release fixes the legacy graphql-ws
10+
handler so subscriptions that complete on their own release their slot and
11+
no longer count towards max_subscriptions_per_connection.
12+
---
13+
14+
This release fixes the legacy `graphql-ws` protocol handler so that
15+
subscriptions which complete on their own (or fail before execution) release
16+
their slot on the connection.
17+
18+
Previously, completed operations were kept in the handler's bookkeeping until
19+
the client sent a `stop` message for them, reused their operation id, or
20+
disconnected. On connections with `max_subscriptions_per_connection`
21+
configured, a client using distinct operation ids could therefore hit
22+
`Subscription limit reached` even though none of its earlier subscriptions
23+
were still active. The `graphql-transport-ws` handler was not affected.
24+
25+
Sending a `stop` message for an operation that has already completed is now a
26+
no-op instead of an error.

strawberry/subscriptions/protocols/graphql_ws/handlers.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -215,17 +215,28 @@ async def handle_async_results(
215215

216216
except asyncio.CancelledError:
217217
await self.send_message(CompleteMessage(type="complete", id=operation_id))
218+
finally:
219+
# The operation is over, whether it completed on its own, failed
220+
# before execution or was stopped. Release its bookkeeping so that
221+
# it no longer counts towards ``max_subscriptions_per_connection``
222+
# and its id can be reused right away.
223+
self.subscriptions.pop(operation_id, None)
224+
self.tasks.pop(operation_id, None)
218225

219226
async def cleanup_operation(self, operation_id: str) -> None:
220-
if operation_id in self.subscriptions:
227+
result_source = self.subscriptions.pop(operation_id, None)
228+
if result_source is not None:
221229
with suppress(RuntimeError):
222-
await self.subscriptions[operation_id].aclose()
223-
del self.subscriptions[operation_id]
230+
await result_source.aclose()
231+
232+
task = self.tasks.pop(operation_id, None)
233+
if task is None:
234+
# Already finished (and released itself), or never existed
235+
return
224236

225-
self.tasks[operation_id].cancel()
237+
task.cancel()
226238
with suppress(BaseException):
227-
await self.tasks[operation_id]
228-
del self.tasks[operation_id]
239+
await task
229240

230241
async def cleanup(self) -> None:
231242
for operation_id in list(self.tasks.keys()):

tests/websockets/test_graphql_ws.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1030,6 +1030,98 @@ async def test_reusing_operation_id_does_not_count_against_limit(
10301030
await ws.close()
10311031

10321032

1033+
async def test_completed_subscriptions_do_not_count_against_limit(
1034+
http_client_class: type[HttpClient],
1035+
):
1036+
"""Subscriptions that complete on their own must release their slot,
1037+
otherwise a client could hit the limit with operations that are no longer
1038+
active."""
1039+
test_client = http_client_class(schema, max_subscriptions_per_connection=2)
1040+
1041+
async with test_client.ws_connect(
1042+
"/graphql", protocols=[GRAPHQL_WS_PROTOCOL]
1043+
) as ws:
1044+
await ws.send_legacy_message({"type": "connection_init"})
1045+
response: ConnectionAckMessage = await ws.receive_json()
1046+
assert response["type"] == "connection_ack"
1047+
1048+
# Each of these completes on its own after a single result, so the
1049+
# third one must not be rejected even though the limit is 2.
1050+
for operation_id in ("sub1", "sub2", "sub3"):
1051+
await ws.send_legacy_message(
1052+
{
1053+
"type": "start",
1054+
"id": operation_id,
1055+
"payload": {
1056+
"query": 'subscription { echo(message: "Hi") }',
1057+
},
1058+
}
1059+
)
1060+
1061+
data_message: DataMessage = await ws.receive_json()
1062+
assert data_message["type"] == "data"
1063+
assert data_message["id"] == operation_id
1064+
assert data_message["payload"]["data"] == {"echo": "Hi"}
1065+
1066+
complete_message: CompleteMessage = await ws.receive_json()
1067+
assert complete_message["type"] == "complete"
1068+
assert complete_message["id"] == operation_id
1069+
1070+
# Stopping an operation that already completed is a no-op and must
1071+
# not affect the connection.
1072+
await ws.send_legacy_message({"type": "stop", "id": "sub1"})
1073+
1074+
await ws.send_legacy_message(
1075+
{
1076+
"type": "start",
1077+
"id": "sub4",
1078+
"payload": {
1079+
"query": 'subscription { echo(message: "Hi") }',
1080+
},
1081+
}
1082+
)
1083+
data_message = await ws.receive_json()
1084+
assert data_message["type"] == "data"
1085+
assert data_message["id"] == "sub4"
1086+
1087+
complete_message = await ws.receive_json()
1088+
assert complete_message["type"] == "complete"
1089+
assert complete_message["id"] == "sub4"
1090+
1091+
await ws.close()
1092+
1093+
1094+
async def test_failed_subscriptions_do_not_count_against_limit(
1095+
http_client_class: type[HttpClient],
1096+
):
1097+
"""Operations that fail before execution (e.g. validation errors) must
1098+
release their slot as well."""
1099+
test_client = http_client_class(schema, max_subscriptions_per_connection=2)
1100+
1101+
async with test_client.ws_connect(
1102+
"/graphql", protocols=[GRAPHQL_WS_PROTOCOL]
1103+
) as ws:
1104+
await ws.send_legacy_message({"type": "connection_init"})
1105+
response: ConnectionAckMessage = await ws.receive_json()
1106+
assert response["type"] == "connection_ack"
1107+
1108+
for operation_id in ("sub1", "sub2", "sub3"):
1109+
await ws.send_legacy_message(
1110+
{
1111+
"type": "start",
1112+
"id": operation_id,
1113+
"payload": {"query": "subscription { doesNotExist }"},
1114+
}
1115+
)
1116+
1117+
error_message: ErrorMessage = await ws.receive_json()
1118+
assert error_message["type"] == "error"
1119+
assert error_message["id"] == operation_id
1120+
assert error_message["payload"] != {"message": "Subscription limit reached"}
1121+
1122+
await ws.close()
1123+
1124+
10331125
async def test_max_subscriptions_per_connection_disabled(
10341126
http_client_class: type[HttpClient],
10351127
):

0 commit comments

Comments
 (0)