Skip to content

Commit 3958058

Browse files
committed
refactor: Refine async client start/close lifecycle and hook registration
Set _started before __start_up so a failed or in-progress start still tears down through close(). Append plugin hooks to those already registered (config plus any added via add_hook before start) rather than overwriting. Stop the event processor last on close because it may still be sending events the other components generated.
1 parent 948902c commit 3958058

2 files changed

Lines changed: 35 additions & 45 deletions

File tree

ldclient/async_client.py

Lines changed: 29 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -127,11 +127,11 @@ async def variation_eval_fn(key, context):
127127
)
128128

129129
async def start(self, start_wait: float = 5.0) -> None:
130-
"""Start the client: create the HTTP session, data system, and event processor.
130+
"""Start the client's background work: create the shared HTTP session and
131+
start the data source, the big-segment status poll, and the event processor.
131132
132-
Single-shot. Calling start() again after it has started is a no-op. If
133-
start() raises, the client is spent: it is marked closed and later
134-
start() calls are ignored, so construct a new client to retry. Calling
133+
Single-shot. Calling start() again after it has started -- or after a
134+
failed start -- is a no-op; construct a new client to retry. Calling
135135
start() after close() is also a logged no-op.
136136
137137
:param start_wait: seconds to wait for the data source to initialize
@@ -143,48 +143,33 @@ async def start(self, start_wait: float = 5.0) -> None:
143143
if self._started:
144144
return
145145

146-
# __start_up resets the hook list to config.hooks + plugin hooks;
147-
# preserve any hooks registered via add_hook() before start().
148-
pre_start_hooks = [h for h in self.__hooks if h not in self._config.hooks]
146+
self._started = True
149147

150148
try:
151149
await self.__start_up(start_wait)
152-
self._started = True
153150
except BaseException:
154-
# Catch BaseException, not Exception, so a cancelled start
155-
# (CancelledError) also releases the components __start_up began.
156-
# re-raise propagates the error/cancellation; it is not swallowed.
151+
# Catch BaseException so a cancelled start (CancelledError) also
152+
# releases the components __start_up began; re-raise propagates it.
157153
await self._cleanup_partial_start()
158-
self._closed = True
159154
raise
160155

161-
for hook in pre_start_hooks:
162-
self.add_hook(hook)
163-
164156
async def _cleanup_partial_start(self):
165-
"""Release any resources that were partially created during a failed __start_up."""
166-
try:
167-
await self._event_processor.stop()
168-
except Exception:
169-
pass
170-
try:
171-
await self._data_system.stop()
172-
except Exception:
173-
pass
174-
try:
175-
manager = self.__big_segment_store_manager
176-
except AttributeError:
177-
manager = None
178-
if manager is not None:
157+
"""Release any resources that were partially created during a failed
158+
__start_up."""
159+
for name, stop in (
160+
("data system", self._data_system.stop),
161+
("big-segment store manager", self.__big_segment_store_manager.stop),
162+
("event processor", self._event_processor.stop),
163+
):
179164
try:
180-
await manager.stop()
181-
except Exception:
182-
pass
165+
await stop()
166+
except Exception as e:
167+
log.warning("Error stopping %s during failed start: %s", name, e)
183168
if self._session is not None:
184169
try:
185170
await self._session.close()
186-
except Exception:
187-
pass
171+
except Exception as e:
172+
log.warning("Error closing HTTP session during failed start: %s", e)
188173
self._session = None
189174

190175
async def close(self, close_timeout: float = 2.0) -> None:
@@ -197,6 +182,7 @@ async def close(self, close_timeout: float = 2.0) -> None:
197182
return
198183
self._closed = True
199184

185+
# A client that was never started has nothing running to release.
200186
if self._started:
201187
try:
202188
await asyncio.wait_for(self._close_components(), timeout=close_timeout)
@@ -215,15 +201,19 @@ async def _close_components(self):
215201
"""Releases the threads and network connections used by the SDK
216202
components. The public :meth:`close` wraps this with a timeout."""
217203
log.info("Closing LaunchDarkly client..")
218-
await self._event_processor.stop()
219204
await self._data_system.stop()
220205
await self.__big_segment_store_manager.stop()
221206

207+
# The event processor is last because it may still be sending events that were generated by the other components.
208+
await self._event_processor.stop()
209+
222210
async def __start_up(self, start_wait: float):
223211
environment_metadata = get_environment_metadata(self._config, "python-server-sdk-async")
224212
plugin_hooks = get_plugin_hooks(self._config.plugins, environment_metadata)
225213

226-
self.__hooks = self._config.hooks + plugin_hooks
214+
# Append plugin hooks to those already registered (the config hooks plus
215+
# any added via add_hook() before start()), rather than overwriting.
216+
self.__hooks = self.__hooks + plugin_hooks
227217

228218
# Start the big-segment status poll now that a loop is running.
229219
self.__big_segment_store_manager.start()
@@ -325,7 +315,7 @@ def track_migration_op(self, tracker: OpTracker):
325315
event = tracker.build()
326316

327317
if isinstance(event, str):
328-
log.error("error generting migration op event %s; no event will be emitted", event)
318+
log.error("error generating migration op event %s; no event will be emitted", event)
329319
return
330320

331321
self._send_event(event)
@@ -354,7 +344,7 @@ def track(self, event_name: str, context: Context, data: Optional[Any] = None, m
354344
def identify(self, context: Context):
355345
"""Reports details about an evaluation context.
356346
357-
This method simply creates an analytics event containing the context properties, to
347+
This method simply creates an analytics event containing the context properties, so
358348
that LaunchDarkly will know about that context if it does not already.
359349
360350
Evaluating a flag, by calling :func:`variation()` or :func:`variation_detail()`, also
@@ -393,7 +383,7 @@ async def flush(self):
393383
Normally, batches of events are delivered in the background at intervals determined by the
394384
``flush_interval`` property of :class:`ldclient.config.Config`. Calling ``flush()``
395385
schedules the next event delivery to be as soon as possible; however, the delivery still
396-
happens asynchronously on a worker thread, so this method will return immediately.
386+
happens asynchronously in the background, so this method will return immediately.
397387
"""
398388
if self._config.offline:
399389
return

ldclient/testing/test_async_client.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -384,11 +384,11 @@ def boom(*args, **kwargs):
384384

385385
with pytest.raises(RuntimeError):
386386
await client.start()
387-
assert client._closed is True
387+
# A failed start marks the client started (spent); a retry is a no-op.
388+
assert client._started is True
388389

389-
# A retry does not re-run start-up.
390+
# Retry does not re-run start-up (it would raise again if it did).
390391
await client.start()
391-
assert client._started is False
392392

393393

394394
@pytest.mark.asyncio
@@ -415,10 +415,10 @@ def cancel(*args, **kwargs):
415415
with pytest.raises(asyncio.CancelledError):
416416
await client.start()
417417

418-
# Cleanup (which stops the started components) ran, and the instance is spent.
418+
# Cleanup ran (stopping the started components), the instance is spent
419+
# (single-shot via _started), and the CancelledError propagated.
419420
assert cleaned is True
420-
assert client._closed is True
421-
assert client._started is False
421+
assert client._started is True
422422

423423

424424
@pytest.mark.asyncio

0 commit comments

Comments
 (0)