|
| 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