Skip to content

Commit 10f73ae

Browse files
authored
fix: Close the connection client on SSEClient.close() (#67)
1 parent 4a684e3 commit 10f73ae

7 files changed

Lines changed: 113 additions & 21 deletions

File tree

‎README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ An async implementation, `AsyncSSEClient`, is also available for use with `async
2222
pip install launchdarkly-eventsource[async]
2323
```
2424

25+
> [!CAUTION]
26+
> The async implementation (`AsyncSSEClient` and its associated `AsyncConnectStrategy` API) is experimental and should NOT be considered ready for production use. It may change or be removed without notice and is not subject to backwards compatibility guarantees.
27+
>
28+
> Pin to a specific minor version and review the changelog before upgrading.
29+
2530
## Supported Python versions
2631

2732
This version of the package is compatible with Python 3.9 and higher.

‎ld_eventsource/async_client.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ class AsyncSSEClient:
1616
"""
1717
An async client for reading a Server-Sent Events stream.
1818
19+
.. caution::
20+
This feature is experimental and should NOT be considered ready for production
21+
use. It may change or be removed without notice and is not subject to backwards
22+
compatibility guarantees. Pin to a specific minor version and review the changelog
23+
before upgrading.
24+
1925
This is an async/await implementation. The expected usage is to create an ``AsyncSSEClient``
2026
instance (either as an async context manager or directly), then read from it using the async
2127
iterator properties :attr:`events` or :attr:`all`.

‎ld_eventsource/config/async_connect_strategy.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ class AsyncConnectStrategy:
1010
"""
1111
An abstraction for how :class:`.AsyncSSEClient` should obtain an input stream.
1212
13+
.. caution::
14+
This feature is experimental and should NOT be considered ready for production
15+
use. It may change or be removed without notice and is not subject to backwards
16+
compatibility guarantees. Pin to a specific minor version and review the changelog
17+
before upgrading.
18+
1319
The default implementation is :meth:`http()`, which makes HTTP requests with ``aiohttp``.
1420
Or, if you want to consume an input stream from some other source, you can create your own
1521
subclass of :class:`AsyncConnectStrategy`.
@@ -58,6 +64,12 @@ class AsyncConnectionClient:
5864
"""
5965
An object provided by :class:`.AsyncConnectStrategy` that is retained by a single
6066
:class:`.AsyncSSEClient` to perform all connection attempts by that instance.
67+
68+
.. caution::
69+
This feature is experimental and should NOT be considered ready for production
70+
use. It may change or be removed without notice and is not subject to backwards
71+
compatibility guarantees. Pin to a specific minor version and review the changelog
72+
before upgrading.
6173
"""
6274

6375
async def connect(self, last_event_id: Optional[str]) -> AsyncConnectionResult:
@@ -85,6 +97,12 @@ async def __aexit__(self, type, value, traceback):
8597
class AsyncConnectionResult:
8698
"""
8799
The return type of :meth:`AsyncConnectionClient.connect()`.
100+
101+
.. caution::
102+
This feature is experimental and should NOT be considered ready for production
103+
use. It may change or be removed without notice and is not subject to backwards
104+
compatibility guarantees. Pin to a specific minor version and review the changelog
105+
before upgrading.
88106
"""
89107

90108
def __init__(

‎ld_eventsource/reader.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,6 @@ def __init__(
6666
def last_event_id(self):
6767
return self._last_event_id
6868

69-
@property
7069
def events_and_comments(self):
7170
event_type = ""
7271
event_data = None

‎ld_eventsource/sse_client.py‎

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ def __init__(
9595
if isinstance(connect, str):
9696
connect = ConnectStrategy.http(connect)
9797
elif not isinstance(connect, ConnectStrategy):
98-
raise TypeError("request must be either a string or ConnectStrategy")
98+
raise TypeError("connect must be either a string or ConnectStrategy")
9999

100100
self.__base_retry_delay = initial_retry_delay
101101
self.__base_retry_delay_strategy = (
@@ -116,7 +116,7 @@ def __init__(
116116
logger.propagate = False
117117
self.__logger = logger
118118

119-
self.__connection_client = connect.create_client(logger)
119+
self.__connection_client: ConnectionClient = connect.create_client(logger)
120120
self.__connection_result: Optional[ConnectionResult] = None
121121
self._retry_reset_baseline: float = 0
122122
self.__disconnected_time: float = 0
@@ -151,6 +151,7 @@ def close(self):
151151
"""
152152
self.__closed = True
153153
self.interrupt()
154+
self.__connection_client.close()
154155

155156
def interrupt(self):
156157
"""
@@ -181,6 +182,22 @@ def all(self) -> Iterable[Action]:
181182
already active, so you do not need to call :meth:`start()` unless you want to verify that
182183
the stream is connected before trying to read events.
183184
"""
185+
return self._all_generator()
186+
187+
@property
188+
def events(self) -> Iterable[Event]:
189+
"""
190+
An iterable series of :class:`.Event` objects received from the stream.
191+
192+
Use :attr:`all` instead if you also want to know about other kinds of occurrences.
193+
194+
Iterating over this property automatically starts or restarts the stream if it is not
195+
already active, so you do not need to call :meth:`start()` unless you want to verify that
196+
the stream is connected before trying to read events.
197+
"""
198+
return self._events_generator()
199+
200+
def _all_generator(self):
184201
while True:
185202
# Reading implies starting the stream if it isn't already started. We might also
186203
# be restarting since we could have been interrupted at any time.
@@ -194,11 +211,12 @@ def all(self) -> Iterable[Action]:
194211
reader = _SSEReader(lines, self.__last_event_id, None)
195212
error: Optional[Exception] = None
196213
try:
197-
for ec in reader.events_and_comments:
214+
for ec in reader.events_and_comments():
215+
self.__last_event_id = reader.last_event_id
198216
yield ec
199217
if self.__interrupted:
200218
break
201-
# If we finished iterating all of reader.events_and_comments, it means the stream
219+
# If we finished iterating all of reader.events_and_comments(), it means the stream
202220
# was closed without an error.
203221
self.__connection_result = None
204222
except Exception as e:
@@ -226,18 +244,8 @@ def all(self) -> Iterable[Action]:
226244
yield Fault(error)
227245
continue # try to connect again
228246

229-
@property
230-
def events(self) -> Iterable[Event]:
231-
"""
232-
An iterable series of :class:`.Event` objects received from the stream.
233-
234-
Use :attr:`all` instead if you also want to know about other kinds of occurrences.
235-
236-
Iterating over this property automatically starts or restarts the stream if it is not
237-
already active, so you do not need to call :meth:`start()` unless you want to verify that
238-
the stream is connected before trying to read events.
239-
"""
240-
for item in self.all:
247+
def _events_generator(self):
248+
for item in self._all_generator():
241249
if isinstance(item, Event):
242250
yield item
243251

‎ld_eventsource/testing/test_reader.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def test_mixed_terminators(self):
5555

5656
class TestSSEReader:
5757
def expect_output(self, lines, expected):
58-
output = list(_SSEReader(lines).events_and_comments)
58+
output = list(_SSEReader(lines).events_and_comments())
5959
assert output == expected
6060

6161
def test_parses_event_with_all_fields(self):
@@ -98,12 +98,12 @@ def store_retry(value):
9898
got_retry = value
9999

100100
lines = ["retry: 1000"]
101-
list(_SSEReader(lines, None, store_retry).events_and_comments)
101+
list(_SSEReader(lines, None, store_retry).events_and_comments())
102102
assert got_retry == 1000
103103

104104
def test_ignores_retry_interval_if_no_callback_given(self):
105105
lines = ["retry: 1000"]
106-
list(_SSEReader(lines, None, None).events_and_comments)
106+
list(_SSEReader(lines, None, None).events_and_comments())
107107

108108
def test_remembers_last_event_id(self):
109109
lines = [
@@ -124,5 +124,5 @@ def test_remembers_last_event_id(self):
124124
Event("message", "third", None, "b"),
125125
Event("message", "fourth", "", ""),
126126
]
127-
output = list(_SSEReader(lines, last_event_id="a").events_and_comments)
127+
output = list(_SSEReader(lines, last_event_id="a").events_and_comments())
128128
assert output == expected
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""
2+
Parity guard for the hand-maintained synchronous and asynchronous client pairs.
3+
4+
The sync and async implementations in this package are written and maintained by hand as
5+
parallel files. This test asserts that each public sync/async pair exposes the same public
6+
surface, so that a method or property added to one side but forgotten on the other fails CI.
7+
8+
The public surface includes properties as well as methods, so the comparison is over all
9+
public attribute names (anything not starting with ``_``) rather than callables only.
10+
"""
11+
12+
import pytest
13+
14+
from ld_eventsource.async_client import AsyncSSEClient
15+
from ld_eventsource.config.async_connect_strategy import (
16+
AsyncConnectionClient, AsyncConnectionResult, AsyncConnectStrategy)
17+
from ld_eventsource.config.connect_strategy import (ConnectionClient,
18+
ConnectionResult,
19+
ConnectStrategy)
20+
from ld_eventsource.sse_client import SSEClient
21+
22+
23+
def _public_names(cls):
24+
return {name for name in dir(cls) if not name.startswith("_")} - set(dir(object))
25+
26+
27+
# Each param pairs a public synchronous class with its asynchronous counterpart, plus
28+
# allowlists of names intentionally present on only one side. Every allowlisted name must
29+
# carry a justification; if the test flags a genuinely one-sided member, add it here with a
30+
# comment rather than weakening the comparison.
31+
@pytest.mark.parametrize(
32+
"sync_cls, async_cls, sync_only, async_only",
33+
[
34+
pytest.param(SSEClient, AsyncSSEClient, set(), set(), id="SSEClient/AsyncSSEClient"),
35+
pytest.param(ConnectStrategy, AsyncConnectStrategy, set(), set(), id="ConnectStrategy/AsyncConnectStrategy"),
36+
pytest.param(ConnectionClient, AsyncConnectionClient, set(), set(), id="ConnectionClient/AsyncConnectionClient"),
37+
pytest.param(ConnectionResult, AsyncConnectionResult, set(), set(), id="ConnectionResult/AsyncConnectionResult"),
38+
],
39+
)
40+
def test_sync_async_public_surface_matches(sync_cls, async_cls, sync_only, async_only):
41+
sync_names = _public_names(sync_cls)
42+
async_names = _public_names(async_cls)
43+
44+
sync_only_diff = sync_names - async_names - sync_only
45+
async_only_diff = async_names - sync_names - async_only
46+
47+
assert not sync_only_diff, (
48+
f"{sync_cls.__name__} exposes public names with no counterpart on "
49+
f"{async_cls.__name__}: {sorted(sync_only_diff)}. Add the missing member to the "
50+
f"async class, or allowlist it in 'sync_only' with a justification."
51+
)
52+
assert not async_only_diff, (
53+
f"{async_cls.__name__} exposes public names with no counterpart on "
54+
f"{sync_cls.__name__}: {sorted(async_only_diff)}. Add the missing member to the "
55+
f"sync class, or allowlist it in 'async_only' with a justification."
56+
)

0 commit comments

Comments
 (0)