Skip to content

Commit ce18f18

Browse files
committed
feat: Add retry state for RETRY-spec backoff
1 parent 296311f commit ce18f18

3 files changed

Lines changed: 868 additions & 0 deletions

File tree

‎ldclient/impl/retry.py‎

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
"""
2+
Computes how long to wait before a failed operation is tried again.
3+
4+
Each failure falls into one of two classes. A ``NORMAL`` failure is one the
5+
service is expected to recover from soon, so the wait stays short. An
6+
``UNEXPECTED`` failure points to a problem that a person has to fix, such as a
7+
rejected SDK key, so the wait becomes much longer. Only an HTTP status can be
8+
``UNEXPECTED``; every network and TLS failure is ``NORMAL``. Neither class ever
9+
tells the caller to give up. There is always a next attempt.
10+
11+
The wait doubles after each failure, up to a ceiling. A random jitter is then
12+
subtracted, so that many callers do not all try again at the same moment. The
13+
wait never falls below the caller's operating cadence.
14+
15+
:class:`RetryState` holds the state for one caller. Use :func:`for_streaming`
16+
or :func:`for_polling` to build one with the right parameters and reset policy.
17+
"""
18+
19+
# currently excluded from documentation - see docs/README.md
20+
21+
import random
22+
import time
23+
from enum import Enum
24+
from typing import Optional, Protocol
25+
26+
from ldclient.impl.util import log
27+
28+
# The delay bounds of the extended regime, in seconds. A component enters the
29+
# extended regime after an unexpected failure.
30+
EXTENDED_INITIAL_DELAY = 5 * 60
31+
EXTENDED_MAX_DELAY = 60 * 60
32+
33+
# The delay bounds of the normal regime for streaming, in seconds. The initial
34+
# delay is configurable as ``initial_reconnect_delay``.
35+
STREAMING_MAX_DELAY = 30
36+
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.
40+
DEFAULT_INITIAL_RECONNECT_DELAY = 1
41+
42+
# How long streaming must operate without a failure before its retry state
43+
# resets, in seconds.
44+
STREAMING_RESET_INTERVAL = 60
45+
46+
# How many polls in a row must succeed before polling's retry state resets.
47+
POLLING_RESET_SUCCESSES = 2
48+
49+
# HTTP statuses in the 4xx range that are still normal failures. Every other
50+
# 4xx is unexpected.
51+
_NORMAL_4XX_STATUSES = frozenset([400, 408, 429])
52+
53+
# An upper bound on the backoff exponent, so a long outage cannot overflow the
54+
# delay computation. Any real ceiling is reached long before this.
55+
_MAX_BACKOFF_EXPONENT = 30
56+
57+
58+
class FailureKind(Enum):
59+
"""How a failure is classified, which decides how long the next wait is."""
60+
61+
NORMAL = 'normal'
62+
"""A failure the service is expected to recover from without help."""
63+
64+
UNEXPECTED = 'unexpected'
65+
"""A failure that suggests a problem a person has to fix. The component
66+
keeps retrying, but much less often."""
67+
68+
69+
def classify_http_status(status: int) -> FailureKind:
70+
"""
71+
Classifies an HTTP status.
72+
73+
``400``, ``408`` and ``429`` are normal, as is any ``5xx``. Every other
74+
``4xx`` -- including ``401`` and ``403`` -- is unexpected.
75+
"""
76+
if 400 <= status < 500 and status not in _NORMAL_4XX_STATUSES:
77+
return FailureKind.UNEXPECTED
78+
return FailureKind.NORMAL
79+
80+
81+
class ResetPolicy(Protocol):
82+
"""Decides when a component has operated well enough for long enough that
83+
its retry state should reset. This is the only behavioral difference
84+
between streaming and polling."""
85+
86+
def note_healthy(self) -> None:
87+
"""Records that the component is operating normally."""
88+
...
89+
90+
def note_failure(self) -> None:
91+
"""Records a failure, which ends any healthy stretch in progress."""
92+
...
93+
94+
def is_satisfied(self) -> bool:
95+
"""Reports whether the reset condition is met."""
96+
...
97+
98+
99+
class AfterHealthyFor(ResetPolicy):
100+
"""Resets once the component has operated without failing for
101+
``seconds``. This is the streaming policy."""
102+
103+
def __init__(self, seconds: float):
104+
self._healthy_seconds = seconds
105+
self._healthy_since: Optional[float] = None
106+
107+
def note_healthy(self) -> None:
108+
"""Records the monotonic time the component became healthy. Calling
109+
this again while it is still healthy does not move that time."""
110+
if self._healthy_since is None:
111+
self._healthy_since = time.monotonic()
112+
113+
def note_failure(self) -> None:
114+
self._healthy_since = None
115+
116+
def is_satisfied(self) -> bool:
117+
if self._healthy_since is None:
118+
return False
119+
return time.monotonic() - self._healthy_since >= self._healthy_seconds
120+
121+
@property
122+
def healthy_since(self) -> Optional[float]:
123+
"""When the current healthy stretch began, or None if the component is
124+
not currently healthy."""
125+
return self._healthy_since
126+
127+
128+
class AfterConsecutiveSuccesses(ResetPolicy):
129+
"""Resets once ``count`` operations in a row have succeeded. This is the
130+
polling policy."""
131+
132+
def __init__(self, count: int):
133+
self._count = count
134+
self._successes = 0
135+
136+
def note_healthy(self) -> None:
137+
self._successes += 1
138+
139+
def note_failure(self) -> None:
140+
self._successes = 0
141+
142+
def is_satisfied(self) -> bool:
143+
return self._successes >= self._count
144+
145+
@property
146+
def successes(self) -> int:
147+
"""How many operations have succeeded in a row."""
148+
return self._successes
149+
150+
151+
class RetryState:
152+
"""
153+
Tracks how long a data source should wait before its next attempt.
154+
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.
158+
159+
An unexpected failure moves the state to the extended regime, which raises
160+
both delay bounds. The bounds stay raised until the reset condition is met,
161+
so a normal failure that follows cannot lower them.
162+
"""
163+
164+
def __init__(
165+
self,
166+
initial_delay: float,
167+
normal_ceiling: float,
168+
extended_initial_delay: float,
169+
extended_ceiling: float,
170+
reset_policy: ResetPolicy,
171+
operating_cadence: float = 0,
172+
):
173+
"""
174+
:param initial_delay: the delay before the first retry, in seconds
175+
:param normal_ceiling: the longest normal-regime delay, in seconds
176+
:param extended_initial_delay: the delay before the first retry in the
177+
extended regime, in seconds
178+
:param extended_ceiling: the longest extended-regime delay, in seconds
179+
:param reset_policy: decides when the retry state resets
180+
:param operating_cadence: the rate the component normally operates at,
181+
in seconds; no wait is ever shorter than this. Zero disables the
182+
floor, which is what streaming wants.
183+
"""
184+
self._initial_delay = initial_delay
185+
self._normal_ceiling = normal_ceiling
186+
self._extended_initial_delay = extended_initial_delay
187+
self._extended_ceiling = extended_ceiling
188+
self._reset_policy = reset_policy
189+
self._operating_cadence = operating_cadence
190+
191+
self._n = 0
192+
self._extended = False
193+
self._min_delay = initial_delay
194+
self._max_delay = max(normal_ceiling, initial_delay)
195+
self._attempts = 0
196+
# Read before any outcome is recorded, this is the ordinary interval.
197+
self._next_delay = operating_cadence if operating_cadence > 0 else initial_delay
198+
199+
@property
200+
def next_delay(self) -> float:
201+
"""The wait before the next attempt, in seconds, as the last recorded
202+
outcome decided it."""
203+
return self._next_delay
204+
205+
@property
206+
def attempts(self) -> int:
207+
"""How many failures since the last reset. For logging only."""
208+
return self._attempts
209+
210+
@property
211+
def min_delay(self) -> float:
212+
"""The delay the current regime starts from, in seconds."""
213+
return self._min_delay
214+
215+
@property
216+
def max_delay(self) -> float:
217+
"""The longest delay the current regime allows, in seconds."""
218+
return self._max_delay
219+
220+
@property
221+
def operating_cadence(self) -> float:
222+
"""The rate the component normally operates at, in seconds."""
223+
return self._operating_cadence
224+
225+
@property
226+
def in_extended_regime(self) -> bool:
227+
"""Whether an unexpected failure has moved this state to the extended
228+
delay bounds."""
229+
return self._extended
230+
231+
def record_failure(self, kind: FailureKind) -> float:
232+
"""
233+
Records a failed attempt and returns how long to wait before the next
234+
one, in seconds.
235+
236+
The state moves on before the wait is computed, so the wait always
237+
reflects the failure just recorded.
238+
239+
:param kind: how the failure was classified
240+
"""
241+
# Only a time-based policy needs this: nothing runs while a stream is healthy.
242+
self._reset_if_due()
243+
self._attempts += 1
244+
self._reset_policy.note_failure()
245+
246+
if kind is FailureKind.UNEXPECTED and not self._extended:
247+
# Moving to the extended regime raises both bounds and starts the
248+
# delay sequence over. Only the move does this: a later unexpected
249+
# failure keeps counting up, so the delay is not pinned to the
250+
# extended initial delay.
251+
self._extended = True
252+
self._min_delay = self._extended_initial_delay
253+
self._max_delay = max(self._extended_ceiling, self._min_delay)
254+
self._n = 1
255+
else:
256+
self._n += 1
257+
258+
self._next_delay = self._compute_wait()
259+
return self._next_delay
260+
261+
def record_success(self) -> None:
262+
"""
263+
Records a successful operation, and resets the retry state if that is
264+
now enough.
265+
266+
The wait before the next operation becomes the operating cadence, even
267+
when the retry state is still raised, because a backoff wait applies to
268+
a retry and not to every operation.
269+
"""
270+
self._reset_policy.note_healthy()
271+
self._reset_if_due()
272+
self._next_delay = self._operating_cadence
273+
274+
def _reset_if_due(self) -> None:
275+
"""Clears the retry state when the reset policy is satisfied, returning
276+
the delay bounds to the normal regime."""
277+
if not self._reset_policy.is_satisfied():
278+
return
279+
self._n = 0
280+
self._attempts = 0
281+
self._extended = False
282+
self._min_delay = self._initial_delay
283+
self._max_delay = max(self._normal_ceiling, self._initial_delay)
284+
285+
def _compute_wait(self) -> float:
286+
exponent = min(max(self._n - 1, 0), _MAX_BACKOFF_EXPONENT)
287+
delay = min(self._min_delay * (2**exponent), self._max_delay)
288+
jitter = random.random() * delay / 2
289+
return max(delay - jitter, self._operating_cadence)
290+
291+
292+
def for_streaming(initial_reconnect_delay: float) -> RetryState:
293+
"""
294+
Builds the retry state for a streaming data source.
295+
296+
Streaming has no operating cadence, so there is no floor on the wait. It
297+
is healthy from the first message of a fresh stream, and resets after a
298+
minute of that.
299+
300+
A configured delay of zero or less would reconnect with no wait, so the
301+
documented default stands in for it. ``Config`` does not check this value,
302+
though it does clamp ``poll_interval``.
303+
304+
The extended regime never starts below the configured delay.
305+
"""
306+
if initial_reconnect_delay <= 0:
307+
log.warning(
308+
"initial_reconnect_delay must be greater than zero; using the default of %ss"
309+
% DEFAULT_INITIAL_RECONNECT_DELAY
310+
)
311+
initial_reconnect_delay = DEFAULT_INITIAL_RECONNECT_DELAY
312+
return RetryState(
313+
initial_delay=initial_reconnect_delay,
314+
normal_ceiling=STREAMING_MAX_DELAY,
315+
extended_initial_delay=max(EXTENDED_INITIAL_DELAY, initial_reconnect_delay),
316+
extended_ceiling=EXTENDED_MAX_DELAY,
317+
reset_policy=AfterHealthyFor(STREAMING_RESET_INTERVAL),
318+
)
319+
320+
321+
def for_polling(poll_interval: float) -> RetryState:
322+
"""
323+
Builds the retry state for a polling data source.
324+
325+
The poll interval is polling's operating cadence, so no wait is ever
326+
shorter than it. In the normal regime the delay bounds are the poll
327+
interval itself, which means a normal failure simply polls again on
328+
schedule. Polling is healthy on any successful poll, and resets after two
329+
in a row.
330+
"""
331+
return RetryState(
332+
initial_delay=poll_interval,
333+
normal_ceiling=poll_interval,
334+
extended_initial_delay=max(EXTENDED_INITIAL_DELAY, poll_interval),
335+
extended_ceiling=EXTENDED_MAX_DELAY,
336+
reset_policy=AfterConsecutiveSuccesses(POLLING_RESET_SUCCESSES),
337+
operating_cadence=poll_interval,
338+
)

0 commit comments

Comments
 (0)