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