Skip to content

Commit cd3dd63

Browse files
committed
fix: Validate the configured data source intervals in Config
Config accepted any initial_reconnect_delay and clamped poll_interval with max(), which let NaN and inf through because every comparison against NaN is false. A NaN interval reaches Event.wait() and the delay arithmetic downstream. Both are now validated, warn, and fall back to the documented default; the poll interval keeps its 30s minimum on top. Move the check to impl/util.py as validate_positive_finite, next to the validators Config already imports, so config and retry share it without either importing the other. The retry factories keep their own call: a RetryState can be built without going through Config. Also name the delay bounds after the spec's ceiling vocabulary.
1 parent e51858c commit cd3dd63

9 files changed

Lines changed: 108 additions & 78 deletions

File tree

‎ldclient/async_config.py‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
from ldclient.config import (
1616
DEFAULT_BASE_URI,
1717
DEFAULT_EVENTS_URI,
18+
DEFAULT_INITIAL_RECONNECT_DELAY,
19+
DEFAULT_POLL_INTERVAL,
1820
DEFAULT_STREAM_URI,
1921
GET_LATEST_FEATURES_PATH,
2022
STREAM_FLAGS_PATH,
@@ -28,6 +30,7 @@
2830
from ldclient.impl.util import (
2931
log,
3032
validate_application_info,
33+
validate_positive_finite,
3134
validate_sdk_key_format
3235
)
3336
from ldclient.interfaces import (
@@ -149,11 +152,11 @@ def __init__(
149152
flush_interval: float = 5,
150153
stream_uri: str = DEFAULT_STREAM_URI,
151154
stream: bool = True,
152-
initial_reconnect_delay: float = 1,
155+
initial_reconnect_delay: float = DEFAULT_INITIAL_RECONNECT_DELAY,
153156
defaults: dict = {},
154157
send_events: Optional[bool] = None,
155158
update_processor_class: Optional[Callable[['AsyncConfig', AsyncFeatureStore, AsyncEvent], AsyncUpdateProcessor]] = None,
156-
poll_interval: float = 30,
159+
poll_interval: float = DEFAULT_POLL_INTERVAL,
157160
use_ldd: bool = False,
158161
feature_store: Optional[AsyncFeatureStore] = None,
159162
feature_requester_class=None,
@@ -255,8 +258,13 @@ def __init__(
255258
self.__stream_uri = stream_uri.rstrip('/')
256259
self.__update_processor_class = update_processor_class
257260
self.__stream = stream
258-
self.__initial_reconnect_delay = initial_reconnect_delay
259-
self.__poll_interval = max(poll_interval, 30.0)
261+
self.__initial_reconnect_delay = validate_positive_finite(
262+
initial_reconnect_delay, DEFAULT_INITIAL_RECONNECT_DELAY, 'initial_reconnect_delay', log
263+
)
264+
self.__poll_interval = max(
265+
validate_positive_finite(poll_interval, DEFAULT_POLL_INTERVAL, 'poll_interval', log),
266+
DEFAULT_POLL_INTERVAL,
267+
)
260268
self.__use_ldd = use_ldd
261269
self.__feature_store = AsyncInMemoryFeatureStore() if not feature_store else feature_store
262270
self.__event_processor_class = event_processor_class

‎ldclient/config.py‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from ldclient.impl.util import (
1616
log,
1717
validate_application_info,
18+
validate_positive_finite,
1819
validate_sdk_key_format
1920
)
2021
from ldclient.interfaces import (
@@ -36,6 +37,11 @@
3637
DEFAULT_EVENTS_URI = 'https://events.launchdarkly.com'
3738
DEFAULT_STREAM_URI = 'https://stream.launchdarkly.com'
3839

40+
# Defaults, in seconds, for the two configurable data source intervals. The
41+
# poll interval is also its own minimum.
42+
DEFAULT_INITIAL_RECONNECT_DELAY = 1
43+
DEFAULT_POLL_INTERVAL = 30
44+
3945

4046
class BigSegmentsConfig:
4147
"""Configuration options related to Big Segments.
@@ -295,11 +301,11 @@ def __init__(
295301
flush_interval: float = 5,
296302
stream_uri: str = DEFAULT_STREAM_URI,
297303
stream: bool = True,
298-
initial_reconnect_delay: float = 1,
304+
initial_reconnect_delay: float = DEFAULT_INITIAL_RECONNECT_DELAY,
299305
defaults: dict = {},
300306
send_events: Optional[bool] = None,
301307
update_processor_class: Optional[Callable[['Config', FeatureStore, Event], UpdateProcessor]] = None,
302-
poll_interval: float = 30,
308+
poll_interval: float = DEFAULT_POLL_INTERVAL,
303309
use_ldd: bool = False,
304310
feature_store: Optional[FeatureStore] = None,
305311
feature_requester_class=None,
@@ -401,8 +407,13 @@ def __init__(
401407
self.__stream_uri = stream_uri.rstrip('/')
402408
self.__update_processor_class = update_processor_class
403409
self.__stream = stream
404-
self.__initial_reconnect_delay = initial_reconnect_delay
405-
self.__poll_interval = max(poll_interval, 30.0)
410+
self.__initial_reconnect_delay = validate_positive_finite(
411+
initial_reconnect_delay, DEFAULT_INITIAL_RECONNECT_DELAY, 'initial_reconnect_delay', log
412+
)
413+
self.__poll_interval = max(
414+
validate_positive_finite(poll_interval, DEFAULT_POLL_INTERVAL, 'poll_interval', log),
415+
DEFAULT_POLL_INTERVAL,
416+
)
406417
self.__use_ldd = use_ldd
407418
self.__feature_store = InMemoryFeatureStore() if not feature_store else feature_store
408419
self.__event_processor_class = event_processor_class

‎ldclient/impl/retry.py‎

Lines changed: 26 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -18,24 +18,24 @@
1818

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

21-
import math
2221
import random
2322
import time
2423
from enum import Enum
2524
from typing import Optional, Protocol
2625

27-
from ldclient.impl.util import log
26+
from ldclient.config import (
27+
DEFAULT_INITIAL_RECONNECT_DELAY,
28+
DEFAULT_POLL_INTERVAL
29+
)
30+
from ldclient.impl.util import log, validate_positive_finite
2831

29-
# The documented defaults, in seconds. Each stands in for a configured value
30-
# that is not a positive, finite number.
31-
DEFAULT_STREAMING_INITIAL_RECONNECT_DELAY = 1
32-
DEFAULT_STREAMING_MAX_DELAY = 30
33-
DEFAULT_POLL_INTERVAL = 30
32+
# The delay ceiling of the normal regime for streaming, in seconds.
33+
NORMAL_STREAMING_CEILING_DELAY = 30
3434

3535
# The delay bounds of the extended regime, in seconds. A component enters the
3636
# extended regime after an unexpected failure.
3737
EXTENDED_INITIAL_DELAY = 5 * 60
38-
EXTENDED_MAX_DELAY = 60 * 60
38+
EXTENDED_CEILING_DELAY = 60 * 60
3939

4040
# How long streaming must operate without a failure before its retry state
4141
# resets, in seconds.
@@ -152,35 +152,37 @@ class RetryState:
152152
def __init__(
153153
self,
154154
normal_initial_delay: float,
155-
normal_ceiling: float,
155+
normal_ceiling_delay: float,
156156
extended_initial_delay: float,
157-
extended_ceiling: float,
157+
extended_ceiling_delay: float,
158158
reset_policy: ResetPolicy,
159159
operating_cadence: float = 0,
160160
):
161161
"""
162162
:param normal_initial_delay: the delay before the first retry in the
163163
normal regime, in seconds
164-
:param normal_ceiling: the longest normal-regime delay, in seconds
164+
:param normal_ceiling_delay: the longest normal-regime delay, in
165+
seconds
165166
:param extended_initial_delay: the delay before the first retry in the
166167
extended regime, in seconds
167-
:param extended_ceiling: the longest extended-regime delay, in seconds
168+
:param extended_ceiling_delay: the longest extended-regime delay, in
169+
seconds
168170
:param reset_policy: decides when the retry state resets
169171
:param operating_cadence: the wait between healthy operations, in
170172
seconds; no wait is ever shorter than this. Zero for a component
171173
that operates continuously.
172174
"""
173175
self._normal_initial_delay = normal_initial_delay
174-
self._normal_ceiling = normal_ceiling
176+
self._normal_ceiling_delay = normal_ceiling_delay
175177
self._extended_initial_delay = extended_initial_delay
176-
self._extended_ceiling = extended_ceiling
178+
self._extended_ceiling_delay = extended_ceiling_delay
177179
self._reset_policy = reset_policy
178180
self._operating_cadence = operating_cadence
179181

180182
self._n = 0
181183
self._extended = False
182184
self._min_delay = self._normal_initial_delay
183-
self._max_delay = max(self._normal_ceiling, self._normal_initial_delay)
185+
self._max_delay = max(self._normal_ceiling_delay, self._normal_initial_delay)
184186
self._attempts = 0
185187
# Read before any outcome is recorded, this is the ordinary interval.
186188
self._next_delay = self._operating_cadence
@@ -212,7 +214,7 @@ def record_failure(self, kind: FailureKind) -> None:
212214
# extended initial delay.
213215
self._extended = True
214216
self._min_delay = self._extended_initial_delay
215-
self._max_delay = max(self._extended_ceiling, self._min_delay)
217+
self._max_delay = max(self._extended_ceiling_delay, self._min_delay)
216218
self._n = 1
217219
else:
218220
self._n += 1
@@ -245,20 +247,7 @@ def _reset_if_due(self) -> None:
245247
self._attempts = 0
246248
self._extended = False
247249
self._min_delay = self._normal_initial_delay
248-
self._max_delay = max(self._normal_ceiling, self._normal_initial_delay)
249-
250-
251-
def _positive_finite(value: float, default: float, name: str) -> float:
252-
"""Returns ``value`` if it is a positive, finite number of seconds, and the
253-
default otherwise. A non-finite value would make the jitter arithmetic
254-
produce a NaN delay, and a non-positive one would retry with no wait."""
255-
if value > 0 and math.isfinite(value):
256-
return value
257-
log.warning(
258-
"%s must be a positive, finite number of seconds; using the default of %ss"
259-
% (name, default)
260-
)
261-
return default
250+
self._max_delay = max(self._normal_ceiling_delay, self._normal_initial_delay)
262251

263252

264253
def for_streaming(initial_reconnect_delay: float) -> RetryState:
@@ -276,14 +265,14 @@ def for_streaming(initial_reconnect_delay: float) -> RetryState:
276265
277266
The extended regime never starts below the configured delay.
278267
"""
279-
initial_reconnect_delay = _positive_finite(
280-
initial_reconnect_delay, DEFAULT_STREAMING_INITIAL_RECONNECT_DELAY, 'initial_reconnect_delay'
268+
initial_reconnect_delay = validate_positive_finite(
269+
initial_reconnect_delay, DEFAULT_INITIAL_RECONNECT_DELAY, 'initial_reconnect_delay', log
281270
)
282271
return RetryState(
283272
normal_initial_delay=initial_reconnect_delay,
284-
normal_ceiling=DEFAULT_STREAMING_MAX_DELAY,
273+
normal_ceiling_delay=NORMAL_STREAMING_CEILING_DELAY,
285274
extended_initial_delay=max(EXTENDED_INITIAL_DELAY, initial_reconnect_delay),
286-
extended_ceiling=EXTENDED_MAX_DELAY,
275+
extended_ceiling_delay=EXTENDED_CEILING_DELAY,
287276
reset_policy=AfterHealthyFor(STREAMING_RESET_INTERVAL),
288277
operating_cadence=0
289278
)
@@ -302,12 +291,12 @@ def for_polling(poll_interval: float) -> RetryState:
302291
``Config`` clamps the poll interval, but the documented default stands in
303292
for anything that reaches here and is not a positive, finite number.
304293
"""
305-
poll_interval = _positive_finite(poll_interval, DEFAULT_POLL_INTERVAL, 'poll_interval')
294+
poll_interval = validate_positive_finite(poll_interval, DEFAULT_POLL_INTERVAL, 'poll_interval', log)
306295
return RetryState(
307296
normal_initial_delay=poll_interval,
308-
normal_ceiling=poll_interval,
297+
normal_ceiling_delay=poll_interval,
309298
extended_initial_delay=max(EXTENDED_INITIAL_DELAY, poll_interval),
310-
extended_ceiling=EXTENDED_MAX_DELAY,
299+
extended_ceiling_delay=EXTENDED_CEILING_DELAY,
311300
reset_policy=AfterConsecutiveSuccesses(POLLING_RESET_SUCCESSES),
312301
operating_cadence=poll_interval,
313302
)

‎ldclient/impl/util.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
import math
23
import re
34
import sys
45
import time
@@ -60,6 +61,25 @@ def validate_application_value(value: Any, name: str, logger: logging.Logger) ->
6061
return value
6162

6263

64+
def validate_positive_finite(value: float, default: float, name: str, logger: logging.Logger) -> float:
65+
"""
66+
Validates that a number of seconds is positive and finite.
67+
68+
A non-finite value makes later arithmetic produce NaN, and a non-positive
69+
one makes a wait no wait at all.
70+
71+
:param value: the number of seconds to validate
72+
:param default: the value to use when ``value`` is not usable
73+
:param name: the option name, for the warning message
74+
:param logger: the logger to use for logging warnings
75+
:return: ``value``, or ``default`` if ``value`` is not positive and finite
76+
"""
77+
if value > 0 and math.isfinite(value):
78+
return value
79+
logger.warning("%s must be a positive, finite number of seconds; using the default of %ss" % (name, default))
80+
return default
81+
82+
6383
def validate_sdk_key_format(sdk_key: str, logger: logging.Logger) -> str:
6484
"""
6585
Validates that an SDK key does not contain invalid characters and is not too long for our systems.

‎ldclient/testing/impl/datasource/test_async_polling.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,9 @@ def fast_retry_state(delay=0.001):
6363
real extended-regime delay of five minutes."""
6464
return RetryState(
6565
normal_initial_delay=delay,
66-
normal_ceiling=delay,
66+
normal_ceiling_delay=delay,
6767
extended_initial_delay=delay,
68-
extended_ceiling=delay,
68+
extended_ceiling_delay=delay,
6969
reset_policy=AfterConsecutiveSuccesses(POLLING_RESET_SUCCESSES),
7070
operating_cadence=delay,
7171
)

‎ldclient/testing/impl/datasource/test_async_streaming.py‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@
2222
)
2323
from ldclient.impl.model import ModelEntity
2424
from ldclient.impl.retry import (
25-
DEFAULT_STREAMING_MAX_DELAY,
25+
EXTENDED_CEILING_DELAY,
2626
EXTENDED_INITIAL_DELAY,
27-
EXTENDED_MAX_DELAY,
27+
NORMAL_STREAMING_CEILING_DELAY,
2828
STREAMING_RESET_INTERVAL,
2929
AfterHealthyFor,
3030
RetryState,
@@ -98,9 +98,9 @@ def _retry_state_with(policy: AfterHealthyFor) -> RetryState:
9898
test can watch the window."""
9999
return RetryState(
100100
normal_initial_delay=0.001,
101-
normal_ceiling=0.001,
101+
normal_ceiling_delay=0.001,
102102
extended_initial_delay=0.001,
103-
extended_ceiling=0.001,
103+
extended_ceiling_delay=0.001,
104104
reset_policy=policy,
105105
)
106106

@@ -110,9 +110,9 @@ def _fast_retry_state(delay: float = 0.001) -> RetryState:
110110
real extended-regime delay of five minutes."""
111111
return RetryState(
112112
normal_initial_delay=delay,
113-
normal_ceiling=delay,
113+
normal_ceiling_delay=delay,
114114
extended_initial_delay=delay,
115-
extended_ceiling=delay,
115+
extended_ceiling_delay=delay,
116116
reset_policy=AfterHealthyFor(STREAMING_RESET_INTERVAL),
117117
)
118118

@@ -137,9 +137,9 @@ def _zero_delay_retry_state() -> RetryState:
137137
real, so a misclassification still shows up in ``max_delay``."""
138138
return RetryState(
139139
normal_initial_delay=0,
140-
normal_ceiling=DEFAULT_STREAMING_MAX_DELAY,
140+
normal_ceiling_delay=NORMAL_STREAMING_CEILING_DELAY,
141141
extended_initial_delay=EXTENDED_INITIAL_DELAY,
142-
extended_ceiling=EXTENDED_MAX_DELAY,
142+
extended_ceiling_delay=EXTENDED_CEILING_DELAY,
143143
reset_policy=AfterHealthyFor(STREAMING_RESET_INTERVAL),
144144
)
145145

@@ -488,7 +488,7 @@ async def test_transport_failures_stay_in_the_normal_regime(error):
488488
assert await asyncio.wait_for(proc._handle_error(error), timeout=2.0)
489489

490490
assert not retry._extended
491-
assert retry._max_delay == DEFAULT_STREAMING_MAX_DELAY
491+
assert retry._max_delay == NORMAL_STREAMING_CEILING_DELAY
492492

493493

494494
class _NoSleep:

‎ldclient/testing/impl/datasource/test_polling_processor.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,9 @@ def fast_retry_state(delay=0.05):
5151
real extended-regime delay of five minutes."""
5252
return RetryState(
5353
normal_initial_delay=delay,
54-
normal_ceiling=delay,
54+
normal_ceiling_delay=delay,
5555
extended_initial_delay=delay,
56-
extended_ceiling=delay,
56+
extended_ceiling_delay=delay,
5757
reset_policy=AfterConsecutiveSuccesses(POLLING_RESET_SUCCESSES),
5858
operating_cadence=delay,
5959
)

‎ldclient/testing/impl/datasource/test_streaming.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,9 @@ def fast_retry_state(delay=brief_delay):
6464
real extended-regime delay of five minutes."""
6565
return RetryState(
6666
normal_initial_delay=delay,
67-
normal_ceiling=delay,
67+
normal_ceiling_delay=delay,
6868
extended_initial_delay=delay,
69-
extended_ceiling=delay,
69+
extended_ceiling_delay=delay,
7070
reset_policy=AfterHealthyFor(STREAMING_RESET_INTERVAL),
7171
)
7272

@@ -565,9 +565,9 @@ def test_several_messages_on_one_stream_do_not_extend_the_reset_window():
565565
policy = AfterHealthyFor(STREAMING_RESET_INTERVAL)
566566
retry = RetryState(
567567
normal_initial_delay=brief_delay,
568-
normal_ceiling=brief_delay,
568+
normal_ceiling_delay=brief_delay,
569569
extended_initial_delay=brief_delay,
570-
extended_ceiling=brief_delay,
570+
extended_ceiling_delay=brief_delay,
571571
reset_policy=policy,
572572
)
573573
# The clock moves on every read, so a window that had been
@@ -601,9 +601,9 @@ def test_a_fresh_stream_starts_a_new_reset_window():
601601
policy = AfterHealthyFor(STREAMING_RESET_INTERVAL)
602602
retry = RetryState(
603603
normal_initial_delay=brief_delay,
604-
normal_ceiling=brief_delay,
604+
normal_ceiling_delay=brief_delay,
605605
extended_initial_delay=brief_delay,
606-
extended_ceiling=brief_delay,
606+
extended_ceiling_delay=brief_delay,
607607
reset_policy=policy,
608608
)
609609
windows = record_healthy_windows(policy)

0 commit comments

Comments
 (0)