Skip to content

Commit e0933d8

Browse files
committed
fix: Guard factory input and keep the next delay positive after a success
1 parent b94c022 commit e0933d8

2 files changed

Lines changed: 109 additions & 25 deletions

File tree

ldclient/impl/retry.py

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
# currently excluded from documentation - see docs/README.md
2020

21+
import math
2122
import random
2223
import time
2324
from enum import Enum
@@ -34,10 +35,10 @@
3435
# delay is configurable as ``initial_reconnect_delay``.
3536
STREAMING_MAX_DELAY = 30
3637

37-
# The documented default for ``initial_reconnect_delay``, in seconds. It stands
38-
# in for a configured value of zero or less, which would reconnect with no wait
39-
# at all.
38+
# The documented defaults, in seconds. Each stands in for a configured value
39+
# that is not a positive, finite number.
4040
DEFAULT_INITIAL_RECONNECT_DELAY = 1
41+
DEFAULT_POLL_INTERVAL = 30
4142

4243
# How long streaming must operate without a failure before its retry state
4344
# resets, in seconds.
@@ -195,7 +196,7 @@ def __init__(
195196
self._max_delay = max(normal_ceiling, initial_delay)
196197
self._attempts = 0
197198
# Read before any outcome is recorded, this is the ordinary interval.
198-
self._next_delay = operating_cadence if operating_cadence > 0 else initial_delay
199+
self._next_delay = self._wait_between_operations()
199200

200201
@property
201202
def next_delay(self) -> float:
@@ -262,13 +263,18 @@ def record_success(self) -> None:
262263
Records a successful operation, and resets the retry state if that is
263264
now enough.
264265
265-
The wait before the next operation becomes the operating cadence, even
266-
when the retry state is still raised, because a backoff wait applies to
267-
a retry and not to every operation.
266+
The wait before the next operation goes back to the ordinary interval,
267+
even when the retry state is still raised, because a backoff wait
268+
applies to a retry and not to every operation.
268269
"""
269270
self._reset_policy.note_healthy()
270271
self._reset_if_due()
271-
self._next_delay = self._operating_cadence
272+
self._next_delay = self._wait_between_operations()
273+
274+
def _wait_between_operations(self) -> float:
275+
"""The wait when nothing is being retried: the operating cadence, or
276+
the initial delay for a component that has no cadence."""
277+
return self._operating_cadence if self._operating_cadence > 0 else self._initial_delay
272278

273279
def _reset_if_due(self) -> None:
274280
"""Clears the retry state when the reset policy is satisfied, returning
@@ -288,6 +294,19 @@ def _compute_wait(self) -> float:
288294
return max(delay - jitter, self._operating_cadence)
289295

290296

297+
def _positive_finite(value: float, default: float, name: str) -> float:
298+
"""Returns ``value`` if it is a positive, finite number of seconds, and the
299+
default otherwise. A non-finite value would make the jitter arithmetic
300+
produce a NaN delay, and a non-positive one would retry with no wait."""
301+
if value > 0 and math.isfinite(value):
302+
return value
303+
log.warning(
304+
"%s must be a positive, finite number of seconds; using the default of %ss"
305+
% (name, default)
306+
)
307+
return default
308+
309+
291310
def for_streaming(initial_reconnect_delay: float) -> RetryState:
292311
"""
293312
Builds the retry state for a streaming data source.
@@ -296,18 +315,14 @@ def for_streaming(initial_reconnect_delay: float) -> RetryState:
296315
is healthy from the first message of a fresh stream, and resets after a
297316
minute of that.
298317
299-
A configured delay of zero or less would reconnect with no wait, so the
300-
documented default stands in for it. ``Config`` does not check this value,
301-
though it does clamp ``poll_interval``.
318+
``Config`` does not check the configured delay, so the documented default
319+
stands in for anything that is not a positive, finite number.
302320
303321
The extended regime never starts below the configured delay.
304322
"""
305-
if initial_reconnect_delay <= 0:
306-
log.warning(
307-
"initial_reconnect_delay must be greater than zero; using the default of %ss"
308-
% DEFAULT_INITIAL_RECONNECT_DELAY
309-
)
310-
initial_reconnect_delay = DEFAULT_INITIAL_RECONNECT_DELAY
323+
initial_reconnect_delay = _positive_finite(
324+
initial_reconnect_delay, DEFAULT_INITIAL_RECONNECT_DELAY, 'initial_reconnect_delay'
325+
)
311326
return RetryState(
312327
initial_delay=initial_reconnect_delay,
313328
normal_ceiling=STREAMING_MAX_DELAY,
@@ -326,7 +341,11 @@ def for_polling(poll_interval: float) -> RetryState:
326341
interval itself, which means a normal failure simply polls again on
327342
schedule. Polling is healthy on any successful poll, and resets after two
328343
in a row.
344+
345+
``Config`` clamps the poll interval, but the documented default stands in
346+
for anything that reaches here and is not a positive, finite number.
329347
"""
348+
poll_interval = _positive_finite(poll_interval, DEFAULT_POLL_INTERVAL, 'poll_interval')
330349
return RetryState(
331350
initial_delay=poll_interval,
332351
normal_ceiling=poll_interval,

ldclient/testing/impl/test_retry.py

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"""
99

1010
import logging
11+
import math
1112
import random
1213
from contextlib import contextmanager
1314
from unittest import mock
@@ -17,6 +18,7 @@
1718
from ldclient.impl import retry
1819
from ldclient.impl.retry import (
1920
DEFAULT_INITIAL_RECONNECT_DELAY,
21+
DEFAULT_POLL_INTERVAL,
2022
EXTENDED_INITIAL_DELAY,
2123
EXTENDED_MAX_DELAY,
2224
POLLING_RESET_SUCCESSES,
@@ -107,24 +109,51 @@ def test_non_error_statuses_are_normal(self, status):
107109
assert classify_http_status(status) is NORMAL
108110

109111

110-
class TestStreamingInitialDelayGuard:
111-
"""``Config`` does not check ``initial_reconnect_delay``, and a value of
112-
zero would reconnect with no wait at all."""
112+
class TestFactoryInputGuards:
113+
"""``Config`` does not check ``initial_reconnect_delay`` at all, and only
114+
clamps ``poll_interval``. A non-positive value would retry with no wait; a
115+
non-finite one makes the jitter arithmetic produce NaN."""
113116

114-
@pytest.mark.parametrize("configured", [0, -1, -0.5])
115-
def test_a_non_positive_delay_falls_back_to_the_default(self, configured, caplog):
117+
@pytest.mark.parametrize(
118+
"configured",
119+
[0, -1, -0.5, float('inf'), float('-inf'), float('nan')],
120+
ids=["zero", "negative", "negative-fraction", "inf", "-inf", "nan"],
121+
)
122+
def test_streaming_falls_back_to_the_default(self, configured, caplog):
116123
caplog.set_level(logging.WARNING)
117124

118125
state = for_streaming(configured)
126+
delay = failure_delay(state, NORMAL)
119127

120128
assert state.min_delay == DEFAULT_INITIAL_RECONNECT_DELAY
121-
assert failure_delay(state, NORMAL) == DEFAULT_INITIAL_RECONNECT_DELAY
129+
assert delay == DEFAULT_INITIAL_RECONNECT_DELAY
130+
assert math.isfinite(delay) and delay > 0
122131
assert caplog.records[0].getMessage() == (
123-
"initial_reconnect_delay must be greater than zero; using the default of 1s"
132+
"initial_reconnect_delay must be a positive, finite number of seconds; "
133+
"using the default of 1s"
134+
)
135+
136+
@pytest.mark.parametrize(
137+
"configured",
138+
[0, -5, float('inf'), float('-inf'), float('nan')],
139+
ids=["zero", "negative", "inf", "-inf", "nan"],
140+
)
141+
def test_polling_falls_back_to_the_default(self, configured, caplog):
142+
caplog.set_level(logging.WARNING)
143+
144+
state = for_polling(configured)
145+
delay = failure_delay(state, NORMAL)
146+
147+
assert state.operating_cadence == DEFAULT_POLL_INTERVAL
148+
assert delay == DEFAULT_POLL_INTERVAL
149+
assert math.isfinite(delay) and delay > 0
150+
assert caplog.records[0].getMessage() == (
151+
"poll_interval must be a positive, finite number of seconds; "
152+
"using the default of 30s"
124153
)
125154

126155
@pytest.mark.parametrize("configured", [0.001, 0.5, 1, 5, 45])
127-
def test_a_positive_delay_is_left_alone(self, configured, caplog):
156+
def test_a_positive_streaming_delay_is_left_alone(self, configured, caplog):
128157
caplog.set_level(logging.WARNING)
129158

130159
state = for_streaming(configured)
@@ -133,6 +162,16 @@ def test_a_positive_delay_is_left_alone(self, configured, caplog):
133162
assert failure_delay(state, NORMAL) == configured
134163
assert caplog.records == []
135164

165+
@pytest.mark.parametrize("configured", [0.001, 1, 30, 300, 2 * 60 * 60])
166+
def test_a_positive_poll_interval_is_left_alone(self, configured, caplog):
167+
caplog.set_level(logging.WARNING)
168+
169+
state = for_polling(configured)
170+
171+
assert state.operating_cadence == configured
172+
assert failure_delay(state, NORMAL) == configured
173+
assert caplog.records == []
174+
136175

137176
class TestStreamingExtendedDelayFloor:
138177
"""A delay that applies after an unexpected failure must not be below the
@@ -233,6 +272,32 @@ def test_every_delay_stays_within_the_jitter_bounds(self):
233272
assert base / 2 <= delay <= base
234273

235274

275+
class TestWaitBetweenOperations:
276+
def test_a_streaming_success_does_not_schedule_a_zero_wait(self):
277+
"""Streaming has no cadence, so a success falls back to the initial
278+
delay. Zero would tell a scheduler to run again immediately."""
279+
state = for_streaming(1)
280+
state.record_failure(NORMAL)
281+
state.record_success()
282+
283+
assert state.next_delay == 1
284+
285+
def test_a_polling_success_schedules_the_cadence(self):
286+
state = for_polling(30)
287+
state.record_failure(NORMAL)
288+
state.record_success()
289+
290+
assert state.next_delay == 30
291+
292+
def test_a_fresh_state_and_a_success_agree(self):
293+
"""The constructor and record_success share one expression, so the two
294+
cannot drift apart."""
295+
for state in (for_streaming(5), for_polling(45)):
296+
fresh = state.next_delay
297+
state.record_success()
298+
assert state.next_delay == fresh
299+
300+
236301
class TestStreamingReset:
237302
def test_a_minute_of_healthy_operation_resets_the_state(self):
238303
# The whole minute passes instantly.

0 commit comments

Comments
 (0)