Skip to content

Commit c0000b6

Browse files
committed
fix: Clear a stale interrupt flag and time the attempt after the wait
Addresses review feedback on #519. A new stream connection now clears _interrupted_by_sdk. interrupt() is a no-op when the connection has already gone, so no Fault arrives to clear the flag and it could swallow the next genuine server close, losing one failure and skipping one backoff. Both streaming sources get a test that fails without the change. _connection_attempt_start_time is now read after the retry wait instead of predicted before it, so a clock change during the wait cannot skew the stream-init latency we report. Also drops the certificate-classification comment from all four data sources, says why the bare RetryDelayStrategy is passed to both SSE clients, and rewords AsyncPollingUpdateProcessor.stop() so it is clear that the wait after OFF only drains a poll already in flight.
1 parent 30bb8ec commit c0000b6

7 files changed

Lines changed: 79 additions & 12 deletions

File tree

‎ldclient/impl/aio/transport.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,9 @@ def create(self, url: str, initial_retry_delay: float, query_params=None, sdk_ma
140140
if proxy:
141141
aiohttp_request_options["proxy"] = proxy
142142
if sdk_managed_retry:
143-
# The SSE client's retry is disabled; the SDK owns the delay.
143+
# The SSE client's retry is disabled; the SDK owns the delay. The base
144+
# strategy returns the delay unchanged, so the wait is always zero;
145+
# omitting it would select the library's own backoff.
144146
retry_options: dict = {
145147
"initial_retry_delay": 0,
146148
"retry_delay_strategy": RetryDelayStrategy(),

‎ldclient/impl/datasource/async_polling.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,10 @@ async def stop(self):
6363
if self._data_source_update_sink is not None:
6464
self._data_source_update_sink.update_status(DataSourceState.OFF, None)
6565

66-
# Wait for the current poll to finish before closing the transport, so we do
67-
# not close it while a request is still using it. The close is in a finally
68-
# so an owned transport is still released if stop() is cancelled mid-wait.
66+
# OFF is reported first, so a listener sees the shutdown at once. The wait
67+
# that follows only drains a poll already in flight, so the transport is
68+
# not closed while that request still uses it. The close is in a finally,
69+
# so an owned transport is released even if stop() is cancelled mid-wait.
6970
try:
7071
await self._task.wait_stopped()
7172
finally:
@@ -95,7 +96,6 @@ async def _fetch_and_store(self) -> None:
9596
level = log.error if kind is FailureKind.UNEXPECTED else log.warning
9697
stacktrace = None
9798
except Exception as e:
98-
# A certificate failure lands here too, and is as normal as the rest.
9999
kind = FailureKind.NORMAL
100100
error_info = DataSourceErrorInfo(DataSourceErrorKind.UNKNOWN, 0, time.time(), str(e))
101101
description = "Error encountered when updating flags: %s" % e

‎ldclient/impl/datasource/async_streaming.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ async def _run(self):
8989
self._connection_attempt_start_time = time.time()
9090
async for action in self._sse.all:
9191
if isinstance(action, Start):
92+
# interrupt() is a no-op when the connection has already gone, so
93+
# clear a stale flag here rather than swallow the next real close.
94+
self._interrupted_by_sdk = False
95+
9296
# On reconnect after an error the timer was cleared; reset it here.
9397
# For the initial connect the pre-loop timestamp is already set.
9498
if self._connection_attempt_start_time is None:
@@ -256,7 +260,6 @@ async def _handle_error(self, error: Exception) -> bool:
256260
description = "The server closed the stream connection"
257261
level = log.warning
258262
else:
259-
# A certificate failure lands here too, and is as normal as the rest.
260263
kind = FailureKind.NORMAL
261264
error_info = DataSourceErrorInfo(DataSourceErrorKind.UNKNOWN, 0, time.time(), str(error))
262265
# no stacktrace here because, for a typical connection error, it'll just be a lengthy tour of HTTP client internals
@@ -270,9 +273,12 @@ async def _handle_error(self, error: Exception) -> bool:
270273
if self._data_source_update_sink is not None:
271274
self._data_source_update_sink.update_status(DataSourceState.INTERRUPTED, error_info)
272275

273-
self._connection_attempt_start_time = time.time() + delay
274276
if delay > 0:
275277
await asyncio.sleep(delay)
278+
279+
# Read after the wait, so a clock change during it cannot skew the
280+
# stream-init latency we report.
281+
self._connection_attempt_start_time = time.time()
276282
return self._running
277283

278284
# magic methods for "with" statement (used in testing)

‎ldclient/impl/datasource/polling.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,6 @@ def _poll(self) -> None:
100100
level = log.error if kind is FailureKind.UNEXPECTED else log.warning
101101
stacktrace = None
102102
except Exception as e:
103-
# A certificate failure lands here too, and is as normal as the rest.
104103
kind = FailureKind.NORMAL
105104
error_info = DataSourceErrorInfo(DataSourceErrorKind.UNKNOWN, 0, time.time(), str(e))
106105
description = "Error encountered when updating flags: %s" % e

‎ldclient/impl/datasource/streaming.py‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ def run(self):
7474
self._connection_attempt_start_time = time.time()
7575
for action in self._sse.all:
7676
if isinstance(action, Start):
77+
# interrupt() is a no-op when the connection has already gone, so
78+
# clear a stale flag here rather than swallow the next real close.
79+
self._interrupted_by_sdk = False
7780
record_environment_id(self._data_source_update_sink, action.headers)
7881
elif isinstance(action, Event):
7982
message_ok = False
@@ -137,7 +140,9 @@ def _create_sse_client(self) -> SSEClient:
137140
url=self._uri, headers=http_factory.base_headers, pool=stream_http_factory.create_pool_manager(1, self._uri), urllib3_request_options={"timeout": stream_http_factory.timeout}
138141
),
139142
error_strategy=ErrorStrategy.always_continue(), # we'll make error-handling decisions when we see a Fault
140-
# The SSE client's retry is disabled; the SDK owns the delay.
143+
# The SSE client's retry is disabled; the SDK owns the delay. The base
144+
# strategy returns the delay unchanged, so the wait is always zero;
145+
# omitting it would select the library's own backoff.
141146
initial_retry_delay=0,
142147
retry_delay_strategy=RetryDelayStrategy(),
143148
retry_delay_reset_threshold=0,
@@ -230,7 +235,6 @@ def _handle_error(self, error: Exception) -> bool:
230235
description = "The server closed the stream connection"
231236
level = log.warning
232237
else:
233-
# A certificate failure lands here too, and is as normal as the rest.
234238
kind = FailureKind.NORMAL
235239
error_info = DataSourceErrorInfo(DataSourceErrorKind.UNKNOWN, 0, time.time(), str(error))
236240
# no stacktrace here because, for a typical connection error, it'll just be a lengthy tour of urllib3 internals
@@ -244,8 +248,12 @@ def _handle_error(self, error: Exception) -> bool:
244248
if self._data_source_update_sink is not None:
245249
self._data_source_update_sink.update_status(DataSourceState.INTERRUPTED, error_info)
246250

247-
self._connection_attempt_start_time = time.time() + delay
248-
return not self._stop_event.wait(min(delay, TIMEOUT_MAX))
251+
interrupted = self._stop_event.wait(min(delay, TIMEOUT_MAX))
252+
253+
# Read after the wait, so a clock change during it cannot skew the
254+
# stream-init latency we report.
255+
self._connection_attempt_start_time = time.time()
256+
return not interrupted
249257

250258
# magic methods for "with" statement (used in testing)
251259
def __enter__(self):

‎ldclient/testing/impl/datasource/test_async_streaming.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,30 @@ async def test_our_own_interrupt_is_not_counted_as_a_server_close():
399399
await proc.stop()
400400

401401

402+
@pytest.mark.asyncio
403+
async def test_a_leaked_interrupt_flag_does_not_swallow_a_server_close():
404+
"""interrupt() is a no-op when the connection has already gone, so no
405+
Fault arrives to clear the flag. A new connection must clear it, or the
406+
next genuine close is recorded as ours and the backoff is skipped."""
407+
put_data = _make_put_data()
408+
actions = [
409+
_start(),
410+
_event('put', put_data),
411+
_fault(error=None), # a close the SDK did not ask for
412+
]
413+
414+
retry = _fast_retry_state()
415+
proc, _, _, _ = _make_processor(actions, retry_state=retry)
416+
proc._interrupted_by_sdk = True
417+
proc.start()
418+
await _wait_until(lambda: retry._attempts >= 1)
419+
await asyncio.sleep(0.1)
420+
421+
assert retry._attempts == 1
422+
423+
await proc.stop()
424+
425+
402426
@pytest.mark.asyncio
403427
async def test_unexpected_http_error_keeps_the_processor_running():
404428
"""A rejected SDK key is retried like any other failure. The state never

‎ldclient/testing/impl/datasource/test_streaming.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,34 @@ def listener(s):
499499
assert retry._attempts == 1
500500

501501

502+
def test_a_leaked_interrupt_flag_does_not_swallow_a_server_close():
503+
"""interrupt() is a no-op when the connection has already gone, so no
504+
Fault arrives to clear the flag. A new connection must clear it, or the
505+
next genuine close is recorded as ours and the backoff is skipped."""
506+
store = InMemoryFeatureStore()
507+
ready = Event()
508+
flagv1 = FlagBuilder('flagkey').version(1).build()
509+
flagv2 = FlagBuilder('flagkey').version(2).build()
510+
511+
with start_server() as server:
512+
with stream_content(make_put_event([flagv1])) as stream1:
513+
with stream_content(make_put_event([flagv2])) as stream2:
514+
config = Config(sdk_key='sdk-key', stream_uri=server.uri, initial_reconnect_delay=brief_delay)
515+
server.for_path('/all', SequentialHandler(stream1, stream2))
516+
517+
retry = fast_retry_state()
518+
with StreamingUpdateProcessor(config, store, ready, None, retry_state=retry) as sp:
519+
sp._interrupted_by_sdk = True
520+
sp.start()
521+
ready.wait(start_wait)
522+
assert sp.initialized()
523+
524+
stream1.close()
525+
expect_update(store, FEATURES, flagv2)
526+
527+
assert retry._attempts == 1
528+
529+
502530
def _handle_errors_without_waiting(retry, errors):
503531
"""Drives _handle_error for each error and returns nothing. The stop event
504532
is pre-set so the interruptible wait returns at once."""

0 commit comments

Comments
 (0)