From fd401b5348a8a62b18b823bf9c0dbaf5001a7285 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Fri, 28 Feb 2025 14:32:31 -0500 Subject: [PATCH 1/2] fix: Fix SSE delay reset handling (#39) The default retry policy has exponential backoff with jitter. This retry strategy is reset after some maximum retry interval. Previously, once this reset interval occurred, it would continue to reset every future attempt, effectively disabling the exponential backoff algorithm. This should no longer be the case, as we now reset the baseline used for determine when to reset the strategy each time it is replaced with the base strategy. --- ld_eventsource/sse_client.py | 15 ++++-- .../testing/test_sse_client_retry.py | 53 +++++++++++++++++++ 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/ld_eventsource/sse_client.py b/ld_eventsource/sse_client.py index 1ac774e..a2d0548 100644 --- a/ld_eventsource/sse_client.py +++ b/ld_eventsource/sse_client.py @@ -111,7 +111,7 @@ def __init__( self.__connection_client = connect.create_client(logger) self.__connection_result: Optional[ConnectionResult] = None - self.__connected_time: float = 0 + self._retry_reset_baseline: float = 0 self.__disconnected_time: float = 0 self.__closed = False @@ -248,10 +248,17 @@ def next_retry_delay(self) -> float: return self.__next_retry_delay def _compute_next_retry_delay(self): - if self.__retry_delay_reset_threshold > 0 and self.__connected_time != 0: - connection_duration = time.time() - self.__connected_time + # If the __retry_reset_baseline is 0, then we haven't successfully connected yet. + # + # In those situations, we don't want to reset the retry delay strategy; + # it should continue to double until the retry maximum, and then hold + # steady (- jitter). + if self.__retry_delay_reset_threshold > 0 and self._retry_reset_baseline != 0: + now = time.time() + connection_duration = now - self._retry_reset_baseline if connection_duration >= self.__retry_delay_reset_threshold: self.__current_retry_delay_strategy = self.__base_retry_delay_strategy + self._retry_reset_baseline = now self.__next_retry_delay, self.__current_retry_delay_strategy = ( self.__current_retry_delay_strategy.apply(self.__base_retry_delay) ) @@ -287,7 +294,7 @@ def _try_start(self, can_return_fault: bool) -> Optional[Fault]: # If can_return_fault is false, it means the caller explicitly called start(), in # which case there's no way to return a Fault so we just keep retrying transparently. continue - self.__connected_time = time.time() + self._retry_reset_baseline = time.time() self.__current_error_strategy = self.__base_error_strategy self.__interrupted = False return None diff --git a/ld_eventsource/testing/test_sse_client_retry.py b/ld_eventsource/testing/test_sse_client_retry.py index 39783de..fba2fe8 100644 --- a/ld_eventsource/testing/test_sse_client_retry.py +++ b/ld_eventsource/testing/test_sse_client_retry.py @@ -1,3 +1,5 @@ +from time import sleep + from ld_eventsource import * from ld_eventsource.actions import * from ld_eventsource.config import * @@ -101,6 +103,57 @@ def test_all_iterator_continues_after_retry(): assert client.next_retry_delay == initial_delay * 2 +def test_retry_delay_gets_reset_after_threshold(): + initial_delay = 0.005 + retry_delay_reset_threshold = 0.1 + mock = MockConnectStrategy( + RespondWithData("data: data1\n\n"), + RejectConnection(HTTPStatusError(503)), + ) + with SSEClient( + connect=mock, + error_strategy=ErrorStrategy.always_continue(), + initial_retry_delay=initial_delay, + retry_delay_reset_threshold=retry_delay_reset_threshold, + retry_delay_strategy=RetryDelayStrategy.default(jitter_multiplier=None), + ) as client: + assert client._retry_reset_baseline == 0 + all = client.all + + # Establish a successful connection + item1 = next(all) + assert isinstance(item1, Start) + assert client._retry_reset_baseline != 0 + + item2 = next(all) + assert isinstance(item2, Event) + assert item2.data == 'data1' + + # Stream is dropped and then fails to re-connect, resulting in backoff. + item3 = next(all) + assert isinstance(item3, Fault) + assert client.next_retry_delay == initial_delay + + item4 = next(all) + assert isinstance(item4, Fault) + assert client.next_retry_delay == initial_delay * 2 + + # Sleeping the threshold should reset the retry thresholds + sleep(retry_delay_reset_threshold) + + # Which we see it does here + item5 = next(all) + assert isinstance(item5, Fault) + assert client.next_retry_delay == initial_delay + + # And if we don't sleep long enough, it doesn't get reset. + sleep(retry_delay_reset_threshold / 2) + + item6 = next(all) + assert isinstance(item6, Fault) + assert client.next_retry_delay == initial_delay * 2 + + def test_can_interrupt_and_restart_stream(): initial_delay = 0.005 mock = MockConnectStrategy( From bb2b18921d930e3cb0ceed0ad21f62834d5936f6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 28 Feb 2025 14:34:26 -0500 Subject: [PATCH 2/2] chore(main): release 1.2.2 (#40) :robot: I have created a release *beep* *boop* --- ## [1.2.2](https://github.com/launchdarkly/python-eventsource/compare/1.2.1...1.2.2) (2025-02-28) ### Bug Fixes * Fix SSE delay reset handling ([#39](https://github.com/launchdarkly/python-eventsource/issues/39)) ([fd401b5](https://github.com/launchdarkly/python-eventsource/commit/fd401b5348a8a62b18b823bf9c0dbaf5001a7285)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 7 +++++++ ld_eventsource/version.py | 2 +- pyproject.toml | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 41ea87d..f6a9e15 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.2.1" + ".": "1.2.2" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 9051bf1..3fed4cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to the LaunchDarkly SSE Client for Python will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [1.2.2](https://github.com/launchdarkly/python-eventsource/compare/1.2.1...1.2.2) (2025-02-28) + + +### Bug Fixes + +* Fix SSE delay reset handling ([#39](https://github.com/launchdarkly/python-eventsource/issues/39)) ([fd401b5](https://github.com/launchdarkly/python-eventsource/commit/fd401b5348a8a62b18b823bf9c0dbaf5001a7285)) + ## [1.2.1](https://github.com/launchdarkly/python-eventsource/compare/1.2.0...1.2.1) (2024-12-23) diff --git a/ld_eventsource/version.py b/ld_eventsource/version.py index 12a03d2..366f91c 100644 --- a/ld_eventsource/version.py +++ b/ld_eventsource/version.py @@ -1 +1 @@ -VERSION = "1.2.1" # x-release-please-version +VERSION = "1.2.2" # x-release-please-version diff --git a/pyproject.toml b/pyproject.toml index 0db4dc2..23561d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "launchdarkly-eventsource" -version = "1.2.1" +version = "1.2.2" description = "LaunchDarkly SSE Client" authors = ["LaunchDarkly "] license = "Apache-2.0"