Skip to content

Commit e734558

Browse files
authored
feat: Add retry state for RETRY-spec backoff (#522)
1 parent 9abe8cb commit e734558

3 files changed

Lines changed: 923 additions & 0 deletions

File tree

‎ldclient/impl/retry.py‎

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
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 math
22+
import random
23+
import time
24+
from enum import Enum
25+
from typing import Optional, Protocol
26+
27+
from ldclient.impl.util import log
28+
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+
NORMAL_STREAMING_CEILING_DELAY = 30
33+
DEFAULT_POLL_INTERVAL = 30
34+
35+
# The delay bounds of the extended regime, in seconds. A component enters the
36+
# extended regime after an unexpected failure.
37+
EXTENDED_INITIAL_DELAY = 5 * 60
38+
EXTENDED_CEILING_DELAY = 60 * 60
39+
40+
# How long streaming must operate without a failure before its retry state
41+
# resets, in seconds.
42+
STREAMING_RESET_INTERVAL = 60
43+
44+
# How many polls in a row must succeed before polling's retry state resets.
45+
POLLING_RESET_SUCCESSES = 2
46+
47+
# HTTP statuses in the 4xx range that are still normal failures. Every other
48+
# 4xx is unexpected.
49+
_NORMAL_4XX_STATUSES = frozenset([400, 408, 429])
50+
51+
# An upper bound on the backoff exponent, so a long outage cannot overflow the
52+
# delay computation. Any real ceiling is reached long before this.
53+
_MAX_BACKOFF_EXPONENT = 30
54+
55+
56+
class FailureKind(Enum):
57+
"""How a failure is classified, which decides how long the next wait is."""
58+
59+
NORMAL = 'normal'
60+
"""A failure the service is expected to recover from without help."""
61+
62+
UNEXPECTED = 'unexpected'
63+
"""A failure that suggests a problem a person has to fix. The component
64+
keeps retrying, but much less often."""
65+
66+
67+
def classify_http_status(status: int) -> FailureKind:
68+
"""
69+
Classifies an HTTP status.
70+
71+
``400``, ``408`` and ``429`` are normal, as is any ``5xx``. Every other
72+
``4xx`` -- including ``401`` and ``403`` -- is unexpected.
73+
"""
74+
if 400 <= status < 500 and status not in _NORMAL_4XX_STATUSES:
75+
return FailureKind.UNEXPECTED
76+
return FailureKind.NORMAL
77+
78+
79+
class ResetPolicy(Protocol):
80+
"""Decides when a component has operated well enough for long enough that
81+
its retry state should reset. This is the only behavioral difference
82+
between streaming and polling."""
83+
84+
def note_healthy(self) -> None:
85+
"""Records that the component is operating normally."""
86+
...
87+
88+
def note_failure(self) -> None:
89+
"""Records a failure, which ends any healthy stretch in progress."""
90+
...
91+
92+
def is_satisfied(self) -> bool:
93+
"""Reports whether the reset condition is met."""
94+
...
95+
96+
97+
class AfterHealthyFor(ResetPolicy):
98+
"""Resets once the component has operated without failing for
99+
``seconds``. This is the streaming policy."""
100+
101+
def __init__(self, seconds: float):
102+
self._healthy_seconds = seconds
103+
self._healthy_since: Optional[float] = None
104+
105+
def note_healthy(self) -> None:
106+
"""Records the monotonic time the component became healthy. Calling
107+
this again while it is still healthy does not move that time."""
108+
if self._healthy_since is None:
109+
self._healthy_since = time.monotonic()
110+
111+
def note_failure(self) -> None:
112+
self._healthy_since = None
113+
114+
def is_satisfied(self) -> bool:
115+
if self._healthy_since is None:
116+
return False
117+
return time.monotonic() - self._healthy_since >= self._healthy_seconds
118+
119+
120+
class AfterConsecutiveSuccesses(ResetPolicy):
121+
"""Resets once ``count`` operations in a row have succeeded. This is the
122+
polling policy."""
123+
124+
def __init__(self, count: int):
125+
self._count = count
126+
self._successes = 0
127+
128+
def note_healthy(self) -> None:
129+
self._successes += 1
130+
131+
def note_failure(self) -> None:
132+
self._successes = 0
133+
134+
def is_satisfied(self) -> bool:
135+
return self._successes >= self._count
136+
137+
138+
class RetryState:
139+
"""
140+
Tracks how long a data source should wait before its next attempt.
141+
142+
A failure moves the state on and decides the next wait, which
143+
:attr:`next_delay` reports. The delay is
144+
``min(min_delay * 2 ** (attempts - 1), max_delay)``, less a random jitter
145+
of up to half of it, and never less than the operating cadence.
146+
147+
An unexpected failure moves the state to the extended regime, which raises
148+
both delay bounds. The bounds stay raised until the reset condition is met,
149+
so a normal failure that follows cannot lower them.
150+
"""
151+
152+
def __init__(
153+
self,
154+
normal_initial_delay: float,
155+
normal_ceiling_delay: float,
156+
extended_initial_delay: float,
157+
extended_ceiling_delay: float,
158+
reset_policy: ResetPolicy,
159+
operating_cadence: float = 0,
160+
):
161+
"""
162+
:param normal_initial_delay: the delay before the first retry in the
163+
normal regime, in seconds
164+
:param normal_ceiling_delay: the longest normal-regime delay, in
165+
seconds
166+
:param extended_initial_delay: the delay before the first retry in the
167+
extended regime, in seconds
168+
:param extended_ceiling_delay: the longest extended-regime delay, in
169+
seconds
170+
:param reset_policy: decides when the retry state resets
171+
:param operating_cadence: the wait between healthy operations, in
172+
seconds; no wait is ever shorter than this. Zero for a component
173+
that operates continuously.
174+
"""
175+
self._normal_initial_delay = normal_initial_delay
176+
self._normal_ceiling_delay = normal_ceiling_delay
177+
self._extended_initial_delay = extended_initial_delay
178+
self._extended_ceiling_delay = extended_ceiling_delay
179+
self._reset_policy = reset_policy
180+
self._operating_cadence = operating_cadence
181+
182+
self._attempts = 0
183+
self._extended = False
184+
self._min_delay = self._normal_initial_delay
185+
self._max_delay = max(self._normal_ceiling_delay, self._normal_initial_delay)
186+
# Read before any outcome is recorded, this is the ordinary interval.
187+
self._next_delay = self._operating_cadence
188+
189+
@property
190+
def next_delay(self) -> float:
191+
"""The wait before the next operation, in seconds, as the last recorded
192+
outcome decided it."""
193+
return self._next_delay
194+
195+
def record_failure(self, kind: FailureKind) -> None:
196+
"""
197+
Records a failed attempt, and decides the wait before the next one.
198+
199+
The state moves on before the wait is computed, so :attr:`next_delay`
200+
always reflects the failure just recorded.
201+
202+
:param kind: how the failure was classified
203+
"""
204+
# Only a time-based policy needs this: nothing runs while a stream is healthy.
205+
self._reset_if_due()
206+
self._reset_policy.note_failure()
207+
208+
if kind is FailureKind.UNEXPECTED and not self._extended:
209+
# Moving to the extended regime raises both bounds and starts the
210+
# delay sequence over. Only the move does this: a later unexpected
211+
# failure keeps counting up, so the delay is not pinned to the
212+
# extended initial delay.
213+
self._extended = True
214+
self._min_delay = self._extended_initial_delay
215+
self._max_delay = max(self._extended_ceiling_delay, self._min_delay)
216+
self._attempts = 1
217+
else:
218+
self._attempts += 1
219+
220+
exponent = min(max(self._attempts - 1, 0), _MAX_BACKOFF_EXPONENT)
221+
delay = min(self._min_delay * (2**exponent), self._max_delay)
222+
jitter = random.random() * delay / 2
223+
224+
self._next_delay = max(delay - jitter, self._operating_cadence)
225+
226+
def record_success(self) -> None:
227+
"""
228+
Records a successful operation, and resets the retry state if that is
229+
now enough.
230+
231+
The wait before the next operation goes back to the ordinary interval,
232+
even when the retry state is still raised, because a backoff wait
233+
applies to a retry and not to every operation.
234+
"""
235+
self._reset_policy.note_healthy()
236+
self._reset_if_due()
237+
self._next_delay = self._operating_cadence
238+
239+
def _reset_if_due(self) -> None:
240+
"""Clears the retry state when the reset policy is satisfied, returning
241+
the delay bounds to the normal regime."""
242+
if not self._reset_policy.is_satisfied():
243+
return
244+
self._attempts = 0
245+
self._extended = False
246+
self._min_delay = self._normal_initial_delay
247+
self._max_delay = max(self._normal_ceiling_delay, self._normal_initial_delay)
248+
249+
250+
def _positive_finite(value: float, default: float, name: str) -> float:
251+
"""Returns ``value`` if it is a positive, finite number of seconds, and the
252+
default otherwise. A non-finite value would make the jitter arithmetic
253+
produce a NaN delay, and a non-positive one would retry with no wait."""
254+
if value > 0 and math.isfinite(value):
255+
return value
256+
log.warning(
257+
"%s must be a positive, finite number of seconds; using the default of %ss"
258+
% (name, default)
259+
)
260+
return default
261+
262+
263+
def for_streaming(initial_reconnect_delay: float) -> RetryState:
264+
"""
265+
Builds the retry state for a streaming data source.
266+
267+
Streaming's operating cadence is zero, so there is no delay during
268+
healthy operation. Stream failures use either the normal or extended
269+
initial delay to determine their backoff wait. A stream returns to
270+
healthy operation after establishing a successful connection with no
271+
failures during the ``STREAMING_RESET_INTERVAL``.
272+
273+
``Config`` does not check the configured delay, so the documented default
274+
stands in for anything that is not a positive, finite number.
275+
276+
The extended regime never starts below the configured delay.
277+
"""
278+
initial_reconnect_delay = _positive_finite(
279+
initial_reconnect_delay, DEFAULT_STREAMING_INITIAL_RECONNECT_DELAY, 'initial_reconnect_delay'
280+
)
281+
return RetryState(
282+
normal_initial_delay=initial_reconnect_delay,
283+
normal_ceiling_delay=NORMAL_STREAMING_CEILING_DELAY,
284+
extended_initial_delay=max(EXTENDED_INITIAL_DELAY, initial_reconnect_delay),
285+
extended_ceiling_delay=EXTENDED_CEILING_DELAY,
286+
reset_policy=AfterHealthyFor(STREAMING_RESET_INTERVAL),
287+
operating_cadence=0
288+
)
289+
290+
291+
def for_polling(poll_interval: float) -> RetryState:
292+
"""
293+
Builds the retry state for a polling data source.
294+
295+
The poll interval is polling's operating cadence, so no wait is ever
296+
shorter than it. In the normal regime the delay bounds are the poll
297+
interval itself, which means a normal failure simply polls again on
298+
schedule. Polling is healthy on any successful poll, and resets after two
299+
in a row.
300+
301+
``Config`` clamps the poll interval, but the documented default stands in
302+
for anything that reaches here and is not a positive, finite number.
303+
"""
304+
poll_interval = _positive_finite(poll_interval, DEFAULT_POLL_INTERVAL, 'poll_interval')
305+
return RetryState(
306+
normal_initial_delay=poll_interval,
307+
normal_ceiling_delay=poll_interval,
308+
extended_initial_delay=max(EXTENDED_INITIAL_DELAY, poll_interval),
309+
extended_ceiling_delay=EXTENDED_CEILING_DELAY,
310+
reset_policy=AfterConsecutiveSuccesses(POLLING_RESET_SUCCESSES),
311+
operating_cadence=poll_interval,
312+
)

0 commit comments

Comments
 (0)