Skip to content

Commit 8e17d5f

Browse files
committed
fix: Keep reconnect delays correct when the system clock changes
1 parent 2cfbcfd commit 8e17d5f

6 files changed

Lines changed: 88 additions & 10 deletions

File tree

‎ld_eventsource/async_client.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ def next_retry_delay(self) -> float:
185185

186186
def _compute_next_retry_delay(self):
187187
if self.__retry_delay_reset_threshold > 0 and self._retry_reset_baseline != 0:
188-
now = time.time()
188+
now = time.monotonic()
189189
connection_duration = now - self._retry_reset_baseline
190190
if connection_duration >= self.__retry_delay_reset_threshold:
191191
self.__current_retry_delay_strategy = self.__base_retry_delay_strategy
@@ -203,7 +203,7 @@ async def _try_start(self, can_return_fault: bool):
203203
self.__next_retry_delay
204204
if self.__disconnected_time == 0
205205
else self.__next_retry_delay
206-
- (time.time() - self.__disconnected_time)
206+
- (time.monotonic() - self.__disconnected_time)
207207
)
208208
if delay > 0:
209209
self.__logger.info("Will reconnect after delay of %fs" % delay)
@@ -213,7 +213,7 @@ async def _try_start(self, can_return_fault: bool):
213213
self.__last_event_id
214214
)
215215
except Exception as e:
216-
self.__disconnected_time = time.time()
216+
self.__disconnected_time = time.monotonic()
217217
self._compute_next_retry_delay()
218218
fail_or_continue, self.__current_error_strategy = (
219219
self.__current_error_strategy.apply(e)
@@ -223,7 +223,7 @@ async def _try_start(self, can_return_fault: bool):
223223
if can_return_fault:
224224
return Fault(e)
225225
continue
226-
self._retry_reset_baseline = time.time()
226+
self._retry_reset_baseline = time.monotonic()
227227
self.__current_error_strategy = self.__base_error_strategy
228228
self.__interrupted = False
229229
return Start(self.__connection_result.headers)

‎ld_eventsource/config/error_strategy.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,9 @@ def apply(self, exception: Optional[Exception]) -> Tuple[bool, ErrorStrategy]:
133133
if self.__start_time == 0:
134134
return (
135135
ErrorStrategy.CONTINUE,
136-
_TimeLimitErrorStrategy(self.__max_time, time.time()),
136+
_TimeLimitErrorStrategy(self.__max_time, time.monotonic()),
137137
)
138-
if (time.time() - self.__start_time) < self.__max_time:
138+
if (time.monotonic() - self.__start_time) < self.__max_time:
139139
return (ErrorStrategy.CONTINUE, self)
140140
return (ErrorStrategy.FAIL, self)
141141

‎ld_eventsource/sse_client.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ def _compute_next_retry_delay(self):
276276
# it should continue to double until the retry maximum, and then hold
277277
# steady (- jitter).
278278
if self.__retry_delay_reset_threshold > 0 and self._retry_reset_baseline != 0:
279-
now = time.time()
279+
now = time.monotonic()
280280
connection_duration = now - self._retry_reset_baseline
281281
if connection_duration >= self.__retry_delay_reset_threshold:
282282
self.__current_retry_delay_strategy = self.__base_retry_delay_strategy
@@ -294,7 +294,7 @@ def _try_start(self, can_return_fault: bool) -> Union[None, Start, Fault]:
294294
self.__next_retry_delay
295295
if self.__disconnected_time == 0
296296
else self.__next_retry_delay
297-
- (time.time() - self.__disconnected_time)
297+
- (time.monotonic() - self.__disconnected_time)
298298
)
299299
if delay > 0:
300300
self.__logger.info("Will reconnect after delay of %fs" % delay)
@@ -304,7 +304,7 @@ def _try_start(self, can_return_fault: bool) -> Union[None, Start, Fault]:
304304
self.__last_event_id
305305
)
306306
except Exception as e:
307-
self.__disconnected_time = time.time()
307+
self.__disconnected_time = time.monotonic()
308308
self._compute_next_retry_delay()
309309
fail_or_continue, self.__current_error_strategy = (
310310
self.__current_error_strategy.apply(e)
@@ -316,7 +316,7 @@ def _try_start(self, can_return_fault: bool) -> Union[None, Start, Fault]:
316316
# If can_return_fault is false, it means the caller explicitly called start(), in
317317
# which case there's no way to return a Fault so we just keep retrying transparently.
318318
continue
319-
self._retry_reset_baseline = time.time()
319+
self._retry_reset_baseline = time.monotonic()
320320
self.__current_error_strategy = self.__base_error_strategy
321321
self.__interrupted = False
322322
return Start(self.__connection_result.headers)

‎ld_eventsource/testing/test_async_sse_client_retry.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import asyncio
2+
import itertools
3+
from unittest.mock import patch
24

35
import pytest
46

@@ -198,3 +200,35 @@ async def test_retry_delay_gets_reset_after_threshold():
198200
item6 = await all_iter.__anext__()
199201
assert isinstance(item6, Fault)
200202
assert client.next_retry_delay == initial_delay * 2
203+
204+
205+
@pytest.mark.asyncio
206+
async def test_reconnect_delay_ignores_wall_clock_step():
207+
initial_delay = 0.05
208+
mock = MockAsyncConnectStrategy(
209+
AsyncRejectConnection(HTTPStatusError(503)),
210+
AsyncRespondWithData("data: data1\n\n"),
211+
AsyncExpectNoMoreRequests(),
212+
)
213+
real_sleep = asyncio.sleep
214+
delays = []
215+
216+
async def fake_sleep(delay, *args, **kwargs):
217+
delays.append(delay)
218+
await real_sleep(0)
219+
220+
# Each wall-clock read steps back one hour.
221+
wall_clock = itertools.count(1_000_000_000, -3600)
222+
with patch('time.time', side_effect=lambda: next(wall_clock)), patch(
223+
'ld_eventsource.async_client.asyncio.sleep', fake_sleep
224+
):
225+
async with AsyncSSEClient(
226+
connect=mock,
227+
error_strategy=retry_for_status(503),
228+
initial_retry_delay=initial_delay,
229+
retry_delay_strategy=RetryDelayStrategy.default(jitter_multiplier=None),
230+
) as client:
231+
await client.start()
232+
233+
assert len(delays) == 1
234+
assert 0 < delays[0] <= initial_delay

‎ld_eventsource/testing/test_error_strategy.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import itertools
12
import time
3+
from unittest.mock import patch
24

35
from ld_eventsource.config import *
46

@@ -47,3 +49,18 @@ def test_max_time():
4749

4850
should_raise, next3 = next2.apply(err)
4951
assert should_raise is True
52+
53+
54+
def test_max_time_ignores_wall_clock_step():
55+
strategy = ErrorStrategy.continue_with_time_limit(0.2)
56+
57+
# Each wall-clock read steps back one hour.
58+
wall_clock = itertools.count(1_000_000_000, -3600)
59+
with patch('time.time', side_effect=lambda: next(wall_clock)):
60+
should_raise, next1 = strategy.apply(err)
61+
assert should_raise is False
62+
63+
time.sleep(0.2)
64+
65+
should_raise, next2 = next1.apply(err)
66+
assert should_raise is True

‎ld_eventsource/testing/test_sse_client_retry.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import itertools
12
from time import sleep
3+
from unittest.mock import patch
24

35
from ld_eventsource import *
46
from ld_eventsource.actions import *
@@ -154,6 +156,31 @@ def test_retry_delay_gets_reset_after_threshold():
154156
assert client.next_retry_delay == initial_delay * 2
155157

156158

159+
def test_reconnect_delay_ignores_wall_clock_step():
160+
initial_delay = 0.05
161+
mock = MockConnectStrategy(
162+
RejectConnection(HTTPStatusError(503)),
163+
RespondWithData("data: data1\n\n"),
164+
ExpectNoMoreRequests(),
165+
)
166+
# Each wall-clock read steps back one hour.
167+
wall_clock = itertools.count(1_000_000_000, -3600)
168+
with patch('time.time', side_effect=lambda: next(wall_clock)), patch(
169+
'time.sleep'
170+
) as fake_sleep:
171+
with SSEClient(
172+
connect=mock,
173+
error_strategy=retry_for_status(503),
174+
initial_retry_delay=initial_delay,
175+
retry_delay_strategy=RetryDelayStrategy.default(jitter_multiplier=None),
176+
) as client:
177+
client.start()
178+
179+
delays = [c.args[0] for c in fake_sleep.call_args_list]
180+
assert len(delays) == 1
181+
assert 0 < delays[0] <= initial_delay
182+
183+
157184
def test_old_connection_closed_on_fault_reconnect():
158185
# When a stream ends and the client reconnects, the old connection must be closed
159186
# (rather than left for garbage collection) before the new one is opened.

0 commit comments

Comments
 (0)