Skip to content

Commit e3d46d7

Browse files
authored
Merge pull request #90 from blodow/feat/add_send_timeout
[#89] Add SSE Send Timeout
2 parents 8c1e090 + 0ef461e commit e3d46d7

File tree

6 files changed

+380
-365
lines changed

6 files changed

+380
-365
lines changed

pdm.lock

Lines changed: 251 additions & 357 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

sse_starlette/sse.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
_log = logging.getLogger(__name__)
2525

2626

27+
class SendTimeoutError(TimeoutError):
28+
pass
29+
30+
2731
# https://stackoverflow.com/questions/58133694/graceful-shutdown-of-uvicorn-starlette-app-with-websockets
2832
class AppStatus:
2933
"""helper for monkey-patching the signal-handler of uvicorn"""
@@ -171,6 +175,7 @@ def __init__(
171175
data_sender_callable: Optional[
172176
Callable[[], Coroutine[None, None, None]]
173177
] = None,
178+
send_timeout: Optional[float] = None,
174179
) -> None:
175180
if sep is not None and sep not in ["\r\n", "\r", "\n"]:
176181
raise ValueError(f"sep must be one of: \\r\\n, \\r, \\n, got: {sep}")
@@ -187,6 +192,7 @@ def __init__(
187192
self.media_type = self.media_type if media_type is None else media_type
188193
self.background = background
189194
self.data_sender_callable = data_sender_callable
195+
self.send_timeout = send_timeout
190196

191197
_headers: dict[str, str] = {}
192198
if headers is not None: # pragma: no cover
@@ -245,7 +251,13 @@ async def stream_response(self, send: Send) -> None:
245251
async for data in self.body_iterator:
246252
chunk = ensure_bytes(data, self.sep)
247253
_log.debug(f"chunk: {chunk.decode()}")
248-
await send({"type": "http.response.body", "body": chunk, "more_body": True})
254+
with anyio.move_on_after(self.send_timeout) as timeout:
255+
await send(
256+
{"type": "http.response.body", "body": chunk, "more_body": True}
257+
)
258+
if timeout.cancel_called:
259+
await self.body_iterator.aclose()
260+
raise SendTimeoutError()
249261

250262
async with self._send_lock:
251263
self.active = False

tests/anyio_compat.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import sys
2+
from contextlib import contextmanager
3+
from typing import Generator
4+
5+
# AnyIO v4 introduces a breaking change that groups all exceptions in a task
6+
# group into an exception group.
7+
# This file allows to be compatible with AnyIO <4 and >=4 by unwrapping groups
8+
# if they only contain a single exception. It also supports python <3.11 (before
9+
# exception groups support) and >=3.11.
10+
# Solution as proposed in https://anyio.readthedocs.io/en/stable/migration.html
11+
12+
has_exceptiongroups = True
13+
if sys.version_info < (3, 11):
14+
try:
15+
from exceptiongroup import BaseExceptionGroup
16+
except ImportError:
17+
has_exceptiongroups = False
18+
19+
20+
@contextmanager
21+
def collapse_excgroups() -> Generator[None, None, None]:
22+
try:
23+
yield
24+
except BaseException as exc:
25+
if has_exceptiongroups:
26+
while isinstance(exc, BaseExceptionGroup) and len(exc.exceptions) == 1:
27+
exc = exc.exceptions[0]
28+
29+
raise exc
30+
31+
32+
__all__ = ["collapse_excgroups"]

tests/integration/frozen_client.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""
2+
https://github.com/sysid/sse-starlette/issues/89
3+
Server Simulation:
4+
Run with: uvicorn tests.integration.frozen_client:app
5+
6+
Client Simulation:
7+
% curl -s -N localhost:8000/events > /dev/null
8+
^Z (suspend process -> no consumption of messages but connection alive)
9+
10+
Measure resource consumption:
11+
connections: lsof -i :8000
12+
buffers: netstat -m
13+
"""
14+
import anyio
15+
from starlette.applications import Starlette
16+
from starlette.routing import Route
17+
18+
from sse_starlette import EventSourceResponse
19+
import uvicorn
20+
21+
22+
async def events(request):
23+
async def _event_generator():
24+
try:
25+
i = 0
26+
while True:
27+
i += 1
28+
if i % 100 == 0:
29+
print(i)
30+
yield dict(data={i: " " * 4096})
31+
await anyio.sleep(0.001)
32+
finally:
33+
print("disconnected")
34+
return EventSourceResponse(_event_generator(), send_timeout=10)
35+
36+
app = Starlette(
37+
debug=True,
38+
routes=[
39+
Route("/events", events),
40+
],
41+
)
42+
43+
if __name__ == "__main__":
44+
uvicorn.run(app, host="0.0.0.0", port=8000, log_level="trace", log_config=None) # type: ignore

tests/test_event_source_response.py

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@
66
import anyio
77
import anyio.lowlevel
88
import pytest
9-
from sse_starlette import EventSourceResponse
109
from starlette.testclient import TestClient
1110

11+
from sse_starlette import EventSourceResponse
12+
from sse_starlette.sse import SendTimeoutError
13+
from tests.anyio_compat import collapse_excgroups
14+
1215
_log = logging.getLogger(__name__)
1316

1417

@@ -124,19 +127,49 @@ async def receive():
124127
await anyio.lowlevel.checkpoint()
125128
return {"type": "something"}
126129

130+
response = EventSourceResponse(event_publisher(), ping=1)
127131
with pytest.raises(anyio.WouldBlock) as e:
128-
response = EventSourceResponse(event_publisher(), ping=1)
132+
with collapse_excgroups():
133+
await response({}, receive, send)
129134

130-
await response({}, receive, send)
131135

132-
133-
def test_header_charset():
136+
def test_header_charset(reset_appstatus_event):
134137
async def numbers(minimum, maximum):
135138
for i in range(minimum, maximum + 1):
136-
await asyncio.sleep(0.1)
139+
await anyio.sleep(0.1)
137140
yield i
138141

139142
generator = numbers(1, 5)
140143
response = EventSourceResponse(generator, ping=0.2) # type: ignore
141144
content_type = [h for h in response.raw_headers if h[0].decode() == "content-type"]
142145
assert content_type == [(b"content-type", b"text/event-stream; charset=utf-8")]
146+
147+
148+
@pytest.mark.anyio
149+
async def test_send_timeout(reset_appstatus_event):
150+
# Timeout is set to 0.5s, but `send` will take 1s. Expect SendTimeoutError.
151+
cleanup = False
152+
153+
async def event_publisher():
154+
try:
155+
yield {"event": "some", "data": "any"}
156+
assert False # never reached
157+
finally:
158+
nonlocal cleanup
159+
cleanup = True
160+
161+
async def send(*args, **kwargs):
162+
await anyio.sleep(1.0)
163+
164+
async def receive():
165+
await anyio.lowlevel.checkpoint()
166+
return {"type": "something"}
167+
168+
response = EventSourceResponse(event_publisher(), send_timeout=0.5)
169+
with pytest.raises(SendTimeoutError):
170+
with collapse_excgroups():
171+
await response({}, receive, send)
172+
173+
assert cleanup
174+
175+

tox.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
[tox]
88
minversion = 3.8.0
99
isolated_build = True
10-
envlist = py38,py39,py310,py311
10+
envlist = py38,py39,py310,py311,py312
1111

1212
[gh-actions]
1313
python =

0 commit comments

Comments
 (0)