Skip to content

Commit edf5946

Browse files
authored
fix: Prevent long reconnect delays or skipped backoff after a system clock change (#80)
1 parent 2cfbcfd commit edf5946

6 files changed

Lines changed: 162 additions & 15 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: 72 additions & 1 deletion
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

@@ -187,7 +189,8 @@ async def test_retry_delay_gets_reset_after_threshold():
187189
assert isinstance(item4, Fault)
188190
assert client.next_retry_delay == initial_delay * 2
189191

190-
await asyncio.sleep(retry_delay_reset_threshold)
192+
# Overshoot the threshold, because the clock can tick in ~15ms steps on Windows.
193+
await asyncio.sleep(retry_delay_reset_threshold * 1.5)
191194

192195
item5 = await all_iter.__anext__()
193196
assert isinstance(item5, Fault)
@@ -198,3 +201,71 @@ async def test_retry_delay_gets_reset_after_threshold():
198201
item6 = await all_iter.__anext__()
199202
assert isinstance(item6, Fault)
200203
assert client.next_retry_delay == initial_delay * 2
204+
205+
206+
@pytest.mark.asyncio
207+
async def test_reconnect_delay_ignores_wall_clock_step():
208+
initial_delay = 0.05
209+
mock = MockAsyncConnectStrategy(
210+
AsyncRejectConnection(HTTPStatusError(503)),
211+
AsyncRespondWithData("data: data1\n\n"),
212+
AsyncExpectNoMoreRequests(),
213+
)
214+
real_sleep = asyncio.sleep
215+
delays = []
216+
217+
async def fake_sleep(delay, *args, **kwargs):
218+
delays.append(delay)
219+
await real_sleep(0)
220+
221+
# Each wall-clock read steps back one hour.
222+
wall_clock = itertools.count(1_000_000_000, -3600)
223+
with patch('time.time', side_effect=lambda: next(wall_clock)), patch(
224+
'ld_eventsource.async_client.asyncio.sleep', fake_sleep
225+
):
226+
async with AsyncSSEClient(
227+
connect=mock,
228+
error_strategy=retry_for_status(503),
229+
initial_retry_delay=initial_delay,
230+
retry_delay_strategy=RetryDelayStrategy.default(jitter_multiplier=None),
231+
) as client:
232+
await client.start()
233+
234+
assert len(delays) == 1
235+
assert 0 < delays[0] <= initial_delay
236+
237+
238+
@pytest.mark.asyncio
239+
async def test_retry_delay_reset_ignores_wall_clock_step():
240+
initial_delay = 0.005
241+
mock = MockAsyncConnectStrategy(
242+
AsyncRespondWithData("data: data1\n\n"),
243+
AsyncRejectConnection(HTTPStatusError(503)),
244+
)
245+
real_sleep = asyncio.sleep
246+
247+
async def fake_sleep(delay, *args, **kwargs):
248+
await real_sleep(0)
249+
250+
# Each wall-clock read steps forward one hour.
251+
wall_clock = itertools.count(1_000_000_000, 3600)
252+
with patch('time.time', side_effect=lambda: next(wall_clock)), patch(
253+
'ld_eventsource.async_client.asyncio.sleep', fake_sleep
254+
):
255+
async with AsyncSSEClient(
256+
connect=mock,
257+
error_strategy=ErrorStrategy.always_continue(),
258+
initial_retry_delay=initial_delay,
259+
retry_delay_reset_threshold=1000,
260+
retry_delay_strategy=RetryDelayStrategy.default(jitter_multiplier=None),
261+
) as client:
262+
all_iter = client.all.__aiter__()
263+
assert isinstance(await all_iter.__anext__(), Start)
264+
assert isinstance(await all_iter.__anext__(), Event)
265+
assert isinstance(await all_iter.__anext__(), Fault)
266+
assert client.next_retry_delay == initial_delay
267+
268+
# The connection lasted far less than the threshold, so the
269+
# backoff must not reset.
270+
assert isinstance(await all_iter.__anext__(), Fault)
271+
assert client.next_retry_delay == initial_delay * 2

‎ld_eventsource/testing/test_error_strategy.py‎

Lines changed: 23 additions & 2 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

@@ -35,15 +37,34 @@ def test_max_attempts():
3537

3638

3739
def test_max_time():
38-
strategy = ErrorStrategy.continue_with_time_limit(0.2)
40+
max_time = 0.2
41+
strategy = ErrorStrategy.continue_with_time_limit(max_time)
3942

4043
should_raise, next1 = strategy.apply(err)
4144
assert should_raise is False
4245

4346
should_raise, next2 = next1.apply(err)
4447
assert should_raise is False
4548

46-
time.sleep(0.2)
49+
# Overshoot the limit, because the clock can tick in ~15ms steps on Windows.
50+
time.sleep(max_time * 1.5)
4751

4852
should_raise, next3 = next2.apply(err)
4953
assert should_raise is True
54+
55+
56+
def test_max_time_ignores_wall_clock_step():
57+
max_time = 0.2
58+
strategy = ErrorStrategy.continue_with_time_limit(max_time)
59+
60+
# Each wall-clock read steps back one hour.
61+
wall_clock = itertools.count(1_000_000_000, -3600)
62+
with patch('time.time', side_effect=lambda: next(wall_clock)):
63+
should_raise, next1 = strategy.apply(err)
64+
assert should_raise is False
65+
66+
# Overshoot the limit, because the clock can tick in ~15ms steps on Windows.
67+
time.sleep(max_time * 1.5)
68+
69+
should_raise, next2 = next1.apply(err)
70+
assert should_raise is True

‎ld_eventsource/testing/test_sse_client_retry.py‎

Lines changed: 57 additions & 2 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 *
@@ -138,8 +140,8 @@ def test_retry_delay_gets_reset_after_threshold():
138140
assert isinstance(item4, Fault)
139141
assert client.next_retry_delay == initial_delay * 2
140142

141-
# Sleeping the threshold should reset the retry thresholds
142-
sleep(retry_delay_reset_threshold)
143+
# Overshoot the threshold, because the clock can tick in ~15ms steps on Windows.
144+
sleep(retry_delay_reset_threshold * 1.5)
143145

144146
# Which we see it does here
145147
item5 = next(all)
@@ -154,6 +156,59 @@ def test_retry_delay_gets_reset_after_threshold():
154156
assert client.next_retry_delay == initial_delay * 2
155157

156158

159+
def test_retry_delay_reset_ignores_wall_clock_step():
160+
initial_delay = 0.005
161+
mock = MockConnectStrategy(
162+
RespondWithData("data: data1\n\n"),
163+
RejectConnection(HTTPStatusError(503)),
164+
)
165+
# Each wall-clock read steps forward one hour.
166+
wall_clock = itertools.count(1_000_000_000, 3600)
167+
with patch('time.time', side_effect=lambda: next(wall_clock)), patch('time.sleep'):
168+
with SSEClient(
169+
connect=mock,
170+
error_strategy=ErrorStrategy.always_continue(),
171+
initial_retry_delay=initial_delay,
172+
retry_delay_reset_threshold=1000,
173+
retry_delay_strategy=RetryDelayStrategy.default(jitter_multiplier=None),
174+
) as client:
175+
all = client.all
176+
assert isinstance(next(all), Start)
177+
assert isinstance(next(all), Event)
178+
assert isinstance(next(all), Fault)
179+
assert client.next_retry_delay == initial_delay
180+
181+
# The connection lasted far less than the threshold, so the
182+
# backoff must not reset.
183+
assert isinstance(next(all), Fault)
184+
assert client.next_retry_delay == initial_delay * 2
185+
186+
187+
def test_reconnect_delay_ignores_wall_clock_step():
188+
initial_delay = 0.05
189+
mock = MockConnectStrategy(
190+
RejectConnection(HTTPStatusError(503)),
191+
RespondWithData("data: data1\n\n"),
192+
ExpectNoMoreRequests(),
193+
)
194+
# Each wall-clock read steps back one hour.
195+
wall_clock = itertools.count(1_000_000_000, -3600)
196+
with patch('time.time', side_effect=lambda: next(wall_clock)), patch(
197+
'time.sleep'
198+
) as fake_sleep:
199+
with SSEClient(
200+
connect=mock,
201+
error_strategy=retry_for_status(503),
202+
initial_retry_delay=initial_delay,
203+
retry_delay_strategy=RetryDelayStrategy.default(jitter_multiplier=None),
204+
) as client:
205+
client.start()
206+
207+
delays = [c.args[0] for c in fake_sleep.call_args_list]
208+
assert len(delays) == 1
209+
assert 0 < delays[0] <= initial_delay
210+
211+
157212
def test_old_connection_closed_on_fault_reconnect():
158213
# When a stream ends and the client reconnects, the old connection must be closed
159214
# (rather than left for garbage collection) before the new one is opened.

0 commit comments

Comments
 (0)