Skip to content

Commit 30c2f08

Browse files
committed
refactor: Report the next delay through the property, not a return value
1 parent 9413155 commit 30c2f08

6 files changed

Lines changed: 54 additions & 44 deletions

File tree

‎ldclient/impl/datasource/async_polling.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,8 @@ async def _fetch_and_store(self) -> None:
104104
# logged, the handler has exited and exc_info() is empty.
105105
stacktrace = e
106106

107-
delay = self._retry.record_failure(kind)
107+
self._retry.record_failure(kind)
108+
delay = self._retry.next_delay
108109
level("%s - will retry in %.1fs" % (description, delay), exc_info=stacktrace)
109110

110111
if self._data_source_update_sink is not None:

‎ldclient/impl/datasource/async_streaming.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,8 @@ async def _handle_error(self, error: Exception) -> bool:
266266
description = "Error on stream connection: %s" % error
267267
level = log.warning
268268

269-
delay = self._retry.record_failure(kind)
269+
self._retry.record_failure(kind)
270+
delay = self._retry.next_delay
270271
level("%s - will retry in %.1fs" % (description, delay))
271272

272273
if self._data_source_update_sink is not None:

‎ldclient/impl/datasource/polling.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,8 @@ def _poll(self) -> None:
109109
# logged, the handler has exited and exc_info() is empty.
110110
stacktrace = e
111111

112-
delay = self._retry.record_failure(kind)
112+
self._retry.record_failure(kind)
113+
delay = self._retry.next_delay
113114
level("%s - will retry in %.1fs" % (description, delay), exc_info=stacktrace)
114115

115116
if self._data_source_update_sink is not None:

‎ldclient/impl/datasource/streaming.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,8 @@ def _handle_error(self, error: Exception) -> bool:
245245
description = "Error on stream connection: %s" % error
246246
level = log.warning
247247

248-
delay = self._retry.record_failure(kind)
248+
self._retry.record_failure(kind)
249+
delay = self._retry.next_delay
249250
level("%s - will retry in %.1fs" % (description, delay))
250251

251252
if self._data_source_update_sink is not None:

‎ldclient/impl/retry.py‎

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -152,9 +152,10 @@ class RetryState:
152152
"""
153153
Tracks how long a data source should wait before its next attempt.
154154
155-
A failure moves the state on and returns the wait. The delay for attempt
156-
``n`` is ``min(min_delay * 2 ** (n - 1), max_delay)``, less a random
157-
jitter of up to half of it, and never less than the operating cadence.
155+
A failure moves the state on and decides the next wait, which
156+
:attr:`next_delay` reports. The delay for attempt ``n`` is
157+
``min(min_delay * 2 ** (n - 1), max_delay)``, less a random jitter of up to
158+
half of it, and never less than the operating cadence.
158159
159160
An unexpected failure moves the state to the extended regime, which raises
160161
both delay bounds. The bounds stay raised until the reset condition is met,
@@ -228,13 +229,12 @@ def in_extended_regime(self) -> bool:
228229
delay bounds."""
229230
return self._extended
230231

231-
def record_failure(self, kind: FailureKind) -> float:
232+
def record_failure(self, kind: FailureKind) -> None:
232233
"""
233-
Records a failed attempt and returns how long to wait before the next
234-
one, in seconds.
234+
Records a failed attempt, and decides the wait before the next one.
235235
236-
The state moves on before the wait is computed, so the wait always
237-
reflects the failure just recorded.
236+
The state moves on before the wait is computed, so :attr:`next_delay`
237+
always reflects the failure just recorded.
238238
239239
:param kind: how the failure was classified
240240
"""
@@ -256,7 +256,6 @@ def record_failure(self, kind: FailureKind) -> float:
256256
self._n += 1
257257

258258
self._next_delay = self._compute_wait()
259-
return self._next_delay
260259

261260
def record_success(self) -> None:
262261
"""

‎ldclient/testing/impl/test_retry.py‎

Lines changed: 38 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@ def real_jitter():
8282
yield
8383

8484

85+
def failure_delay(state, kind=NORMAL) -> float:
86+
"""Records a failure and reads back the wait it decided, which is the value
87+
a data source reads."""
88+
state.record_failure(kind)
89+
return state.next_delay
90+
91+
8592
def streaming_state(initial_delay=1):
8693
return for_streaming(initial_delay)
8794

@@ -119,7 +126,7 @@ def test_a_non_positive_delay_falls_back_to_the_default(self, configured, caplog
119126
state = for_streaming(configured)
120127

121128
assert state.min_delay == DEFAULT_INITIAL_RECONNECT_DELAY
122-
assert state.record_failure(NORMAL) == DEFAULT_INITIAL_RECONNECT_DELAY
129+
assert failure_delay(state, NORMAL) == DEFAULT_INITIAL_RECONNECT_DELAY
123130
assert caplog.records[0].getMessage() == (
124131
"initial_reconnect_delay must be greater than zero; using the default of 1s"
125132
)
@@ -131,7 +138,7 @@ def test_a_positive_delay_is_left_alone(self, configured, caplog):
131138
state = for_streaming(configured)
132139

133140
assert state.min_delay == configured
134-
assert state.record_failure(NORMAL) == configured
141+
assert failure_delay(state, NORMAL) == configured
135142
assert caplog.records == []
136143

137144

@@ -144,12 +151,12 @@ class TestStreamingExtendedDelayFloor:
144151
[(1, 300), (30, 300), (300, 300), (600, 600), (3600, 3600), (0, 300), (-5, 300)],
145152
)
146153
def test_the_extended_delay_never_starts_below_the_configured_delay(self, configured, expected):
147-
assert for_streaming(configured).record_failure(UNEXPECTED) == expected
154+
assert failure_delay(for_streaming(configured), UNEXPECTED) == expected
148155

149156
@pytest.mark.parametrize("configured", [1, 30, 300, 600, 3600])
150157
def test_an_unexpected_failure_never_waits_less_than_a_normal_one(self, configured):
151-
normal = for_streaming(configured).record_failure(NORMAL)
152-
unexpected = for_streaming(configured).record_failure(UNEXPECTED)
158+
normal = failure_delay(for_streaming(configured), NORMAL)
159+
unexpected = failure_delay(for_streaming(configured), UNEXPECTED)
153160
assert unexpected >= normal
154161

155162
@pytest.mark.parametrize(
@@ -162,28 +169,28 @@ def test_an_unexpected_failure_never_waits_less_than_a_normal_one(self, configur
162169
)
163170
def test_the_extended_ladder_still_doubles_to_the_ceiling(self, configured, ladder):
164171
state = for_streaming(configured)
165-
delays = [state.record_failure(UNEXPECTED)]
166-
delays += [state.record_failure(NORMAL) for _ in range(4)]
172+
delays = [failure_delay(state, UNEXPECTED)]
173+
delays += [failure_delay(state, NORMAL) for _ in range(4)]
167174
assert delays == ladder
168175

169176

170177
class TestStreamingDelayTable:
171178
def test_normal_regime_doubles_up_to_the_ceiling(self):
172179
state = streaming_state(initial_delay=1)
173-
delays = [state.record_failure(NORMAL) for _ in range(8)]
180+
delays = [failure_delay(state, NORMAL) for _ in range(8)]
174181
assert delays == [1, 2, 4, 8, 16, 30, 30, 30]
175182

176183
def test_extended_regime_doubles_up_to_the_ceiling(self):
177184
state = streaming_state(initial_delay=1)
178-
delays = [state.record_failure(UNEXPECTED)]
179-
delays += [state.record_failure(NORMAL) for _ in range(5)]
185+
delays = [failure_delay(state, UNEXPECTED)]
186+
delays += [failure_delay(state, NORMAL) for _ in range(5)]
180187
assert delays == [5 * 60, 10 * 60, 20 * 60, 40 * 60, 60 * 60, 60 * 60]
181188

182189
def test_a_configured_initial_delay_raises_the_ceiling_with_it(self):
183190
# RETRY 1.5.4 as amended: maxDelay must not fall below initialDelay.
184191
state = streaming_state(initial_delay=45)
185192
assert state.max_delay == 45
186-
assert state.record_failure(NORMAL) == 45
193+
assert failure_delay(state, NORMAL) == 45
187194

188195
def test_the_ceiling_is_sticky_once_the_extended_regime_starts(self):
189196
# RETRY 1.5.5: a normal failure after an unexpected one must not lower
@@ -202,9 +209,9 @@ def test_a_second_unexpected_failure_keeps_counting_up(self):
202209
# Restarting the count on every unexpected failure would pin the delay
203210
# at the extended initial delay for ever.
204211
state = streaming_state(initial_delay=1)
205-
assert state.record_failure(UNEXPECTED) == 5 * 60
206-
assert state.record_failure(UNEXPECTED) == 10 * 60
207-
assert state.record_failure(UNEXPECTED) == 20 * 60
212+
assert failure_delay(state, UNEXPECTED) == 5 * 60
213+
assert failure_delay(state, UNEXPECTED) == 10 * 60
214+
assert failure_delay(state, UNEXPECTED) == 20 * 60
208215

209216
def test_the_streaming_defaults_match_the_spec(self):
210217
state = streaming_state(initial_delay=1)
@@ -217,20 +224,20 @@ class TestJitter:
217224
def test_jitter_never_removes_more_than_half_the_delay(self):
218225
with fixed_retry_jitter(FULL_JITTER):
219226
state = streaming_state(initial_delay=8)
220-
delay = state.record_failure(NORMAL)
227+
delay = failure_delay(state, NORMAL)
221228
assert 4 <= delay < 8
222229

223230
def test_no_jitter_leaves_the_delay_alone(self):
224231
state = streaming_state(initial_delay=8)
225-
assert state.record_failure(NORMAL) == 8
232+
assert failure_delay(state, NORMAL) == 8
226233

227234
def test_every_delay_stays_within_the_jitter_bounds(self):
228235
# The real random source, so the bound has to hold for any draw rather
229236
# than for one seeded sequence.
230237
with real_jitter():
231238
state = streaming_state(initial_delay=1)
232239
for base in [1, 2, 4, 8, 16, 30, 30, 30]:
233-
delay = state.record_failure(NORMAL)
240+
delay = failure_delay(state, NORMAL)
234241
assert base / 2 <= delay <= base
235242

236243

@@ -249,7 +256,7 @@ def test_a_minute_of_healthy_operation_resets_the_state(self):
249256
state.record_success()
250257
assert not state.in_extended_regime
251258
assert state.max_delay == STREAMING_MAX_DELAY
252-
assert state.record_failure(NORMAL) == 1
259+
assert failure_delay(state, NORMAL) == 1
253260

254261
def test_a_reset_also_happens_on_the_failure_that_ends_a_healthy_stretch(self):
255262
with frozen_clock() as clock:
@@ -262,7 +269,7 @@ def test_a_reset_also_happens_on_the_failure_that_ends_a_healthy_stretch(self):
262269

263270
# The state resets before this failure is counted, so the delay is
264271
# the first-retry delay again rather than the fourth.
265-
assert state.record_failure(NORMAL) == 1
272+
assert failure_delay(state, NORMAL) == 1
266273

267274
def test_a_short_healthy_stretch_does_not_reset(self):
268275
with frozen_clock() as clock:
@@ -271,7 +278,7 @@ def test_a_short_healthy_stretch_does_not_reset(self):
271278

272279
state.record_success()
273280
clock.advance(STREAMING_RESET_INTERVAL - 1)
274-
assert state.record_failure(NORMAL) == 2
281+
assert failure_delay(state, NORMAL) == 2
275282

276283
def test_a_fast_flapping_connection_does_not_ratchet_into_the_extended_regime(self):
277284
# Every transport failure is normal, so no amount of flapping reaches
@@ -283,7 +290,7 @@ def test_a_fast_flapping_connection_does_not_ratchet_into_the_extended_regime(se
283290
for _ in range(20):
284291
state.record_success()
285292
clock.advance(5)
286-
delays.append(state.record_failure(NORMAL))
293+
delays.append(failure_delay(state, NORMAL))
287294
clock.advance(1)
288295

289296
assert not state.in_extended_regime
@@ -294,26 +301,26 @@ def test_a_fast_flapping_connection_does_not_ratchet_into_the_extended_regime(se
294301
class TestPollingCadence:
295302
def test_a_normal_failure_polls_again_on_schedule(self):
296303
state = polling_state(poll_interval=30)
297-
assert [state.record_failure(NORMAL) for _ in range(4)] == [30, 30, 30, 30]
304+
assert [failure_delay(state, NORMAL) for _ in range(4)] == [30, 30, 30, 30]
298305

299306
def test_the_extended_regime_doubles_up_to_an_hour(self):
300307
state = polling_state(poll_interval=30)
301-
delays = [state.record_failure(UNEXPECTED)]
302-
delays += [state.record_failure(NORMAL) for _ in range(5)]
308+
delays = [failure_delay(state, UNEXPECTED)]
309+
delays += [failure_delay(state, NORMAL) for _ in range(5)]
303310
assert delays == [5 * 60, 10 * 60, 20 * 60, 40 * 60, 60 * 60, 60 * 60]
304311

305312
def test_the_wait_never_falls_below_the_poll_interval(self):
306313
# RETRY 1.4.9. Full jitter would otherwise halve the delay.
307314
with fixed_retry_jitter(FULL_JITTER):
308315
state = polling_state(poll_interval=30)
309-
assert state.record_failure(NORMAL) == 30
310-
assert state.record_failure(UNEXPECTED) >= 30
316+
assert failure_delay(state, NORMAL) == 30
317+
assert failure_delay(state, UNEXPECTED) >= 30
311318

312319
def test_a_poll_interval_longer_than_the_extended_bounds_wins(self):
313320
# The ceiling is lifted by record_failure clamping it against the
314321
# initial delay, not by for_polling clamping the ceiling itself.
315322
state = polling_state(poll_interval=2 * 60 * 60)
316-
assert state.record_failure(UNEXPECTED) == 2 * 60 * 60
323+
assert failure_delay(state, UNEXPECTED) == 2 * 60 * 60
317324
assert state.max_delay == 2 * 60 * 60
318325
assert state.min_delay == 2 * 60 * 60
319326

@@ -324,7 +331,7 @@ def test_one_success_restores_the_cadence_while_the_state_is_still_raised(self):
324331
state = polling_state(poll_interval=30)
325332
state.record_failure(UNEXPECTED)
326333
state.record_failure(NORMAL)
327-
assert state.record_failure(NORMAL) == 20 * 60
334+
assert failure_delay(state, NORMAL) == 20 * 60
328335

329336
state.record_success()
330337
assert state.next_delay == 30
@@ -341,7 +348,7 @@ def test_two_successes_in_a_row_reset_the_state(self):
341348
state.record_success()
342349
assert not state.in_extended_regime
343350
assert state.next_delay == 30
344-
assert state.record_failure(NORMAL) == 30
351+
assert failure_delay(state, NORMAL) == 30
345352

346353
def test_a_failure_between_two_successes_clears_the_first(self):
347354
state = polling_state(poll_interval=30)
@@ -384,7 +391,7 @@ def test_a_reset_starts_the_attempt_count_over(self):
384391

385392
# The delay drops back to the first-retry value, and the count
386393
# starts over with it.
387-
assert state.record_failure(NORMAL) == 1
394+
assert failure_delay(state, NORMAL) == 1
388395
assert state.attempts == 1
389396

390397

@@ -460,5 +467,5 @@ def test_a_long_outage_cannot_overflow_the_delay(self):
460467
reset_policy=AfterHealthyFor(60),
461468
)
462469
for _ in range(5000):
463-
delay = state.record_failure(NORMAL)
470+
delay = failure_delay(state, NORMAL)
464471
assert delay == 30

0 commit comments

Comments
 (0)