Coverage for bzfs_main/util/retry.py: 100%

570 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-19 18:44 +0000

1# Copyright 2024 Wolfgang Hoschek AT mac DOT com 

2# 

3# Licensed under the Apache License, Version 2.0 (the "License"); 

4# you may not use this file except in compliance with the License. 

5# You may obtain a copy of the License at 

6# 

7# http://www.apache.org/licenses/LICENSE-2.0 

8# 

9# Unless required by applicable law or agreed to in writing, software 

10# distributed under the License is distributed on an "AS IS" BASIS, 

11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

12# See the License for the specific language governing permissions and 

13# limitations under the License. 

14# 

15"""Customizable generic retry framework; defaults to jittered exponential backoff with cap unless specified otherwise. 

16 

17Purpose: 

18-------- 

19- Provide a reusable retry helper for transient failures using customizable policy and callbacks. 

20- Centralize backoff, jitter, logging and metrics behavior while keeping call sites compact. 

21- Prevent accidental retries: the loop retries only when the developer explicitly raises a ``RetryableError``, which reduces 

22 the risk of retrying non-idempotent operations. 

23- Provide a thread-safe, fast implementation; avoid shared RNG contention. 

24- Provide both sync and async API, both with the same semantics, except the async API awaits ``fn``, awaitable 

25 ``before_attempt`` / ``after_attempt`` / ``on_retryable_error`` / ``on_exhaustion`` / ``giveup`` results, and non-blocking 

26 sleep. 

27- Avoid unnecessary complexity and add zero dependencies beyond the Python standard library. Everything you need is in this 

28 single Python file. 

29 

30Usage: 

31------ 

32- Wrap work in a callable ``fn(retry: Retry)`` and therein raise ``RetryableError`` for failures that should be retried. 

33- Construct a policy via ``RetryPolicy(...)`` that specifies how ``RetryableError`` shall be retried. 

34- Invoke ``call_with_retries(fn=fn, policy=policy, log=logger)`` or call_with_retries_async with a standard logging.Logger. 

35- On success, the result of calling ``fn`` is returned. 

36- By default on exhaustion, call_with_retries* either re-raises the last underlying ``RetryableError.__cause__``, or raises 

37 ``RetryError`` (wrapping the last ``RetryableError``), like so: 

38 - if ``RetryPolicy.reraise`` is True and the last ``RetryableError.__cause__`` is not None, re-raise the last 

39 ``RetryableError.__cause__`` with its original traceback. 

40 - Otherwise, raise ``RetryError`` (wrapping the last ``RetryableError``, preserving its ``__cause__`` chain). 

41 - The default is ``RetryPolicy.reraise=True``. 

42 

43Advanced Configuration: 

44----------------------- 

45- Tune ``RetryPolicy`` parameters to control maximum retries, sleep bounds, elapsed-time budget, logging, etc. 

46- Use ``RetryPolicy.config: RetryConfig`` to control logging settings. 

47- Set ``log=None`` to disable logging, or customize ``info_loglevel`` / ``warning_loglevel`` for structured logs. 

48- Supply a ``giveup(AttemptOutcome)`` callback to stop retrying based on domain-specific logic (for example, decisions based 

49 on time budget/quota or the previous N most recent AttemptOutcome objects (via AttemptOutcome.retry.previous_outcomes). 

50- Use the ``any_giveup()`` / ``all_giveup()`` helper to consult more than one callback handler in ``giveup(AttemptOutcome)``. 

51- Supply an ``on_exhaustion(AttemptOutcome)`` callback to customize behavior when giving up; it may raise an error or return 

52 a fallback value. 

53 

54Observability: 

55-------------- 

56- Supply an ``after_attempt(AttemptOutcome)`` callback to collect per-attempt metrics such as success flag, 

57 exhausted/terminated state, attempt number, total elapsed duration (in nanoseconds), sleep duration (in nanoseconds), etc. 

58- ``AttemptOutcome.result`` is either the successful result or the most recent ``RetryableError``, enabling integration with 

59 metrics and tracing systems without coupling the retry loop to any specific backend. 

60- Supply an ``after_attempt(AttemptOutcome)`` callback to customize logging 100%, if necessary. 

61- Use the ``multi_after_attempt()`` helper to invoke more than one callback handler in ``after_attempt(AttemptOutcome)``. 

62 

63Expert Configuration: 

64--------------------- 

65- Supply a ``backoff(BackoffContext)`` callback to plug in a custom backoff algorithm (e.g., decorrelated-jitter or 

66 retry-after HTTP 429). The default is full-jitter exponential backoff with cap (aka industry standard). 

67- Supply a ``before_attempt(Retry)`` callback for optional rate limiting or other forms of internal backpressure. 

68- Supply a ``on_retryable_error(AttemptOutcome)`` callback, e.g. to count failures (RetryableError) caught by the retry loop. 

69- Set ``RetryPolicy.max_previous_outcomes > 0`` to pass the N most recent AttemptOutcome objects to callbacks (default is 0). 

70- If ``RetryPolicy.max_previous_outcomes > 0``, you can use ``RetryableError(..., attachment=...)`` to carry domain-specific 

71 state from a failed attempt to the next attempt via ``retry.previous_outcomes``. This pattern helps if attempt N+1 is a 

72 function of attempt N or all prior attempts (e.g., switching endpoints or resuming from an offset). 

73- Use ``[Async]RetryTemplate`` as a bag-of-knobs configuration template for functions that shall be retried in similar ways. 

74- Or package up all knobs plus a ``fn(retry: Retry)`` function into a self-contained auto-retrying higher level function by 

75 constructing a ``[Async]RetryTemplate`` object (which is a ``Callable`` function itself). 

76- To keep calling code retry-transparent, set ``RetryPolicy.reraise=True`` (the default) *and* raise retryable failures as 

77 ``raise RetryableError(...) from exc``. Client code now won't notice whether call_with_retries* is used or not. 

78- To make exhaustion observable to calling code, set ``RetryPolicy.reraise=False``: by default call_with_retries* now always 

79 raises ``RetryError`` (wrapping the last ``RetryableError``) on exhaustion, so callers now catch ``RetryError`` and can 

80 inspect the last underlying exception via ``err.outcome``, ``err.__cause__``, and even ``err.__cause__.__cause__`` when 

81 present. 

82- Set ``RetryPolicy.timing`` to customize reading the current monotonic time, sleeping and optional async termination. 

83- The callback API is powerful enough to easily plug in advanced retry algorithms such as: 

84 - Google SRE Client-Side Adaptive Throttling - https://sre.google/sre-book/handling-overload/ 

85 - gRPC retry throttling - https://grpc.io/docs/guides/retry/ 

86 - AWS SDK adaptive retry mode - https://docs.aws.amazon.com/sdkref/latest/guide/feature-retry-behavior.html 

87 - Circuit breakers - https://martinfowler.com/bliki/CircuitBreaker.html (e.g. via `pybreaker` third-party library) 

88 - Rate limiting with Fixed Window, Moving Window, and Sliding Windows (e.g. via `limits` third-party library) 

89 - Various example backoff strategies such as decorrelated-jitter or retry-after HTTP 429 "Too Many Requests" responses, 

90 etc can be found in test_retry_examples.py. 

91 

92Example Usage: 

93-------------- 

94 import logging 

95 from bzfs_main.util.retry import Retry, RetryPolicy, RetryableError, call_with_retries 

96 

97 def unreliable_operation(retry: Retry) -> str: 

98 try: 

99 if retry.count < 3: 

100 raise ValueError("temporary failure connecting to foo.example.com") 

101 return "ok" 

102 except ValueError as exc: 

103 # Preserve the underlying cause for correct error propagation and logging 

104 raise RetryableError(display_msg="connect") from exc 

105 

106 retry_policy = RetryPolicy( 

107 max_retries=10, 

108 min_sleep_secs=0, 

109 initial_max_sleep_secs=0.125, 

110 max_sleep_secs=10, 

111 max_elapsed_secs=60, 

112 ) 

113 log = logging.getLogger(__name__) 

114 result: str = call_with_retries(fn=unreliable_operation, policy=retry_policy, log=log) 

115 print(result) 

116 

117 # Sample log output: 

118 # INFO:Retrying connect [1/10] in 8.79ms ... 

119 # INFO:Retrying connect [2/10] in 90.1ms ... 

120 # INFO:Retrying connect [3/10] in 372ms ... 

121 # ok 

122 

123Background: 

124----------- 

125For background on exponential backoff and jitter, see for example 

126https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter 

127""" 

128 

129from __future__ import ( 

130 annotations, 

131) 

132import argparse 

133import dataclasses 

134import functools 

135import inspect 

136import logging 

137import random 

138import sys 

139import threading 

140import time 

141import traceback 

142from collections.abc import ( 

143 Awaitable, 

144 Iterable, 

145 Mapping, 

146 Sequence, 

147) 

148from dataclasses import ( 

149 dataclass, 

150) 

151from typing import ( 

152 TYPE_CHECKING, 

153 Any, 

154 Callable, 

155 Final, 

156 Generic, 

157 NamedTuple, 

158 NoReturn, 

159 TypeVar, 

160 Union, 

161 cast, 

162 final, 

163) 

164 

165if TYPE_CHECKING: 

166 import asyncio 

167 

168if sys.version_info >= (3, 11): 

169 from builtins import ( 

170 BaseExceptionGroup, 

171 ) 

172 

173from bzfs_main.util.utils import ( 

174 human_readable_duration, 

175) 

176 

177# constants: 

178INFINITY_MAX_RETRIES: Final[int] = 2**90 - 1 # a number that's essentially infinity for all practical retry purposes 

179 

180 

181############################################################################# 

182def full_jitter_backoff_strategy(context: BackoffContext) -> tuple[int, int]: 

183 """Default implementation of ``backoff`` callback for call_with_retries(); computes delay time before next retry attempt, 

184 after failure. 

185 

186 Full-jitter picks a random sleep_nanos duration from the range [min_sleep_nanos, curr_max_sleep_nanos] and applies 

187 exponential backoff with cap to the next attempt; thread-safe. Typically, min_sleep_nanos is 0 and exponential_base is 2. 

188 Example curr_max_sleep_nanos sequence: 125ms --> 250ms --> 500ms --> 1s --> 2s --> 4s --> 8s --> 10s --> 10s... 

189 Full-jitter provides optimal balance between reducing server load and minimizing retry latency. 

190 """ 

191 policy: RetryPolicy = context.retry.policy 

192 curr_max_sleep_nanos: int = context.curr_max_sleep_nanos 

193 if policy.min_sleep_nanos == curr_max_sleep_nanos: 

194 sleep_nanos = curr_max_sleep_nanos # perf 

195 else: 

196 sleep_nanos = context.rng.randint(policy.min_sleep_nanos, curr_max_sleep_nanos) # nanos to delay until next attempt 

197 curr_max_sleep_nanos = round(curr_max_sleep_nanos * policy.exponential_base) # exponential backoff 

198 curr_max_sleep_nanos = min(curr_max_sleep_nanos, policy.max_sleep_nanos) # ... with cap for next attempt 

199 return sleep_nanos, curr_max_sleep_nanos 

200 

201 

202def no_giveup(outcome: AttemptOutcome) -> object | None: 

203 """Default implementation of ``giveup`` callback for call_with_retries(); never gives up; returning anything other than 

204 ``None`` indicates to give up retrying; thread-safe.""" 

205 return None # don't give up retrying 

206 

207 

208def giveup_if_backoff_exceeds_deadline(outcome: AttemptOutcome) -> object | None: 

209 """Implementation of ``giveup`` callback for call_with_retries() that gives up if the computed backoff sleep exceeds the 

210 ``RetryPolicy.max_elapsed_secs`` deadline; thread-safe.""" 

211 return ( 

212 None 

213 if outcome.elapsed_nanos + outcome.sleep_nanos < outcome.retry.policy.max_elapsed_nanos 

214 else "backoff exceeds deadline" 

215 ) 

216 

217 

218def before_attempt_noop(retry: Retry) -> int: 

219 """Default implementation of ``before_attempt`` callback for call_with_retries(); does nothing; thread-safe.""" 

220 return 0 

221 

222 

223def after_attempt_log_failure(outcome: AttemptOutcome) -> None: 

224 """Default implementation of ``after_attempt`` callback for call_with_retries(); performs simple logging of retry attempt 

225 failures; thread-safe.""" 

226 retry: Retry = outcome.retry 

227 policy: RetryPolicy = retry.policy 

228 config: RetryConfig = policy.config 

229 if outcome.is_success or retry.log is None or not config.enable_logging: 

230 return 

231 log: logging.Logger = retry.log 

232 assert isinstance(outcome.result, RetryableError) 

233 retryable_error: RetryableError = outcome.result 

234 if not outcome.is_exhausted: 

235 if log.isEnabledFor(config.info_loglevel): # Retrying X in Y ms ... 

236 m1: str = config.format_msg(config.display_msg, retryable_error) 

237 m2: str = config.format_pair(retry.count + 1, policy.max_retries) 

238 m3: str = config.format_duration(outcome.sleep_nanos) 

239 log.log(config.info_loglevel, "%s", f"{m1}{m2} in {m3}{config.dots}", extra=config.extra) 

240 else: 

241 if policy.max_retries > 0 and log.isEnabledFor(config.warning_loglevel) and not outcome.is_terminated: 

242 reason: str = "" if outcome.giveup_reason is None else f"{outcome.giveup_reason}; " 

243 format_duration: Callable[[int], str] = config.format_duration # lambda: nanos 

244 log.log( 

245 config.warning_loglevel, 

246 "%s", 

247 f"{config.format_msg(config.display_msg, retryable_error)}" 

248 f"exhausted; giving up because {reason}the last " 

249 f"{config.format_pair(retry.count, policy.max_retries)} retries across " 

250 f"{config.format_pair(format_duration(outcome.elapsed_nanos), format_duration(policy.max_elapsed_nanos))} " 

251 "failed", 

252 exc_info=retryable_error if config.exc_info else None, 

253 stack_info=config.stack_info, 

254 extra=config.extra, 

255 ) 

256 

257 

258def noop(outcome: AttemptOutcome) -> None: 

259 """Default implementation of ``on_retryable_error`` callback for call_with_retries(); does nothing; thread-safe.""" 

260 

261 

262def on_exhaustion_raise(outcome: AttemptOutcome) -> NoReturn: 

263 """Default implementation of ``on_exhaustion`` callback for call_with_retries(); always raises; thread-safe.""" 

264 assert outcome.is_exhausted 

265 assert isinstance(outcome.result, RetryableError) 

266 retryable_error: RetryableError = outcome.result 

267 policy: RetryPolicy = outcome.retry.policy 

268 cause: BaseException | None = retryable_error.__cause__ 

269 if policy.reraise and cause is not None: 

270 raise cause.with_traceback(cause.__traceback__) 

271 else: 

272 raise RetryError(outcome=outcome) from retryable_error 

273 

274 

275def _update_previous_outcomes( 

276 previous_outcomes: tuple[AttemptOutcome, ...], outcome: AttemptOutcome, policy: RetryPolicy, retry: Retry 

277) -> tuple[AttemptOutcome, ...]: 

278 """Computes value of previous_outcomes for next retry iteration.""" 

279 n: int = policy.max_previous_outcomes 

280 if n > 0: # outcome will be passed to next attempt via Retry.previous_outcomes 

281 if previous_outcomes: # detach to reduce memory footprint 

282 outcome = outcome.copy(retry=retry.copy(previous_outcomes=())) 

283 if len(previous_outcomes) >= n: 

284 evicted: AttemptOutcome = previous_outcomes[0] 

285 assert isinstance(evicted.result, RetryableError) 

286 _clear_exception_frames(evicted.result) # eviction: drop references to local variables to bound memory footprint 

287 previous_outcomes = previous_outcomes[1:] 

288 previous_outcomes = previous_outcomes + (outcome,) 

289 return previous_outcomes 

290 

291 

292def _clear_exception_frames(error: BaseException) -> None: 

293 """Clear traceback references to local variables throughout the exception graph of causes, contexts, exception-groups.""" 

294 # on Python < 3.13 traceback.clear_frames() closes live generators and coroutines, which would be a bug. 

295 if sys.version_info >= (3, 13): 

296 pending: list[BaseException] = [error] 

297 seen: set[int] = set() 

298 while pending: 

299 current: BaseException = pending.pop() 

300 if id(current) in seen: 

301 continue # graph cycle protection 

302 seen.add(id(current)) 

303 for linked in (current.__cause__, current.__context__): 

304 if linked is not None: 

305 pending.append(linked) 

306 if isinstance(current, BaseExceptionGroup): 

307 pending.extend(current.exceptions) 

308 traceback.clear_frames(current.__traceback__) 

309 

310 

311############################################################################# 

312_T = TypeVar("_T") 

313 

314 

315def call_with_retries( 

316 fn: Callable[[Retry], _T], # typically a lambda; wraps work and raises RetryableError for failures that shall be retried 

317 policy: RetryPolicy, # specifies how ``RetryableError`` shall be retried 

318 *, 

319 backoff: BackoffStrategy = full_jitter_backoff_strategy, # computes delay time before next retry attempt, after failure 

320 giveup: Callable[[AttemptOutcome], object | None] = no_giveup, # stop retrying based on domain-specific logic, e.g. time 

321 before_attempt: Callable[[Retry], int] = before_attempt_noop, # e.g. wait due to rate limiting or internal backpressure 

322 after_attempt: Callable[[AttemptOutcome], None] = after_attempt_log_failure, # e.g. record metrics and/or custom logging 

323 on_retryable_error: Callable[[AttemptOutcome], None] = noop, # e.g. count failures (RetryableError) caught by retry loop 

324 on_exhaustion: Callable[[AttemptOutcome], _T] = on_exhaustion_raise, # raise error or return fallback value 

325 log: logging.Logger | None = None, # set this to ``None`` to disable logging 

326) -> _T: 

327 """Runs the function ``fn`` and returns its result; retries on failure as indicated by policy; thread-safe. 

328 

329 By default on exhaustion, call_with_retries() either re-raises the last underlying ``RetryableError.__cause__``, or raises 

330 ``RetryError`` (wrapping the last ``RetryableError``), like so: 

331 - if ``RetryPolicy.reraise`` is True and the last ``RetryableError.__cause__`` is not None, re-raise the last 

332 ``RetryableError.__cause__`` with its original traceback. 

333 - Otherwise, raise ``RetryError`` (wrapping the last ``RetryableError``, preserving its ``__cause__`` chain). 

334 - The default is ``RetryPolicy.reraise=True``. 

335 

336 On the exhaustion path, ``on_exhaustion`` will be called exactly once (after the final after_attempt). The default 

337 implementation raises as described above; custom ``on_exhaustion`` impls may return a fallback value instead of an error. 

338 """ 

339 rng: random.Random | None = None 

340 retry_count: int = 0 

341 idle_nanos: int = 0 

342 curr_max_sleep_nanos: int = policy.initial_max_sleep_nanos 

343 previous_outcomes: tuple[AttemptOutcome, ...] = () # for safety pass *immutable* deque to callbacks 

344 timing: RetryTiming = policy.timing 

345 sleep: Callable[[int, Retry], None] = timing.sleep 

346 monotonic_ns: Callable[[], int] = timing.monotonic_ns 

347 call_start_nanos: Final[int] = monotonic_ns() 

348 while True: 

349 before_attempt_nanos: int = monotonic_ns() if retry_count != 0 else call_start_nanos 

350 prev: tuple[AttemptOutcome, ...] = previous_outcomes 

351 retry: Retry = Retry( 

352 retry_count, call_start_nanos, before_attempt_nanos, before_attempt_nanos, idle_nanos, policy, log, prev 

353 ) 

354 try: 

355 if before_attempt is not before_attempt_noop: 

356 before_attempt_sleep_nanos: int = before_attempt(retry) 

357 assert before_attempt_sleep_nanos >= 0, before_attempt_sleep_nanos 

358 if before_attempt_sleep_nanos > 0: 

359 sleep(before_attempt_sleep_nanos, retry) # e.g. wait due to rate limiting or internal backpressure 

360 attempt_start_nanos: int = monotonic_ns() 

361 idle_nanos += attempt_start_nanos - before_attempt_nanos 

362 retry = Retry( 

363 retry_count, call_start_nanos, before_attempt_nanos, attempt_start_nanos, idle_nanos, policy, log, prev 

364 ) 

365 timing.on_before_attempt(retry) 

366 result: _T = fn(retry) # Call the target function and supply retry attempt number and other metadata 

367 except RetryableError as retryable_error: 

368 elapsed_nanos: int = monotonic_ns() - call_start_nanos 

369 is_terminated: Callable[[Retry], bool] = timing.is_terminated 

370 giveup_reason: object | None = None 

371 sleep_nanos: int = 0 

372 outcome = AttemptOutcome(retry, False, False, False, None, elapsed_nanos, sleep_nanos, retryable_error) 

373 on_retryable_error(outcome) # e.g. count failures (RetryableError) caught by retry loop 

374 if retry_count < policy.max_retries and elapsed_nanos < policy.max_elapsed_nanos: 

375 if policy.max_sleep_nanos == 0 and backoff is full_jitter_backoff_strategy: 

376 pass # perf: e.g. spin-before-block 

377 elif retry_count == 0 and retryable_error.retry_immediately_once: 

378 pass # retry once immediately without backoff 

379 else: # jitter: default backoff strategy picks random sleep_nanos in [min_sleep_nanos, curr_max_sleep_nanos] 

380 rng = _thread_local_rng() if rng is None else rng 

381 sleep_nanos, curr_max_sleep_nanos = backoff( # compute delay before next retry attempt, after failure 

382 BackoffContext(retry, curr_max_sleep_nanos, rng, elapsed_nanos, retryable_error) 

383 ) 

384 assert sleep_nanos >= 0 and curr_max_sleep_nanos >= 0, (sleep_nanos, curr_max_sleep_nanos) 

385 

386 if sleep_nanos > 0: 

387 outcome = AttemptOutcome(retry, False, False, False, None, elapsed_nanos, sleep_nanos, retryable_error) 

388 if (not is_terminated(retry)) and (giveup_reason := giveup(outcome)) is None: 

389 after_attempt(outcome) 

390 sleep(sleep_nanos, retry) 

391 idle_nanos += sleep_nanos 

392 if not is_terminated(retry): 

393 previous_outcomes = _update_previous_outcomes(previous_outcomes, outcome, policy, retry) 

394 del outcome # help gc 

395 retry_count += 1 

396 continue # continue retry loop with next attempt 

397 else: 

398 sleep_nanos = 0 

399 outcome = AttemptOutcome( 

400 retry, False, True, is_terminated(retry), giveup_reason, elapsed_nanos, sleep_nanos, retryable_error 

401 ) 

402 after_attempt(outcome) 

403 return on_exhaustion(outcome) # raise error or return fallback value 

404 else: # success 

405 if after_attempt is not after_attempt_log_failure: 

406 elapsed_nanos = monotonic_ns() - call_start_nanos 

407 outcome = AttemptOutcome(retry, True, False, False, None, elapsed_nanos, 0, result) 

408 after_attempt(outcome) 

409 return result 

410 

411 

412async def call_with_retries_async( 

413 fn: Callable[[Retry], Awaitable[_T]], # wraps work and raises RetryableError for failures that shall be retried 

414 policy: RetryPolicy, # specifies how ``RetryableError`` shall be retried 

415 *, 

416 backoff: BackoffStrategy = full_jitter_backoff_strategy, # computes delay time before next retry attempt, after failure 

417 giveup: Callable[[AttemptOutcome], object | Awaitable] = no_giveup, # stop retrying based on custom logic, e.g. time 

418 before_attempt: Callable[[Retry], int | Awaitable[int]] = before_attempt_noop, # e.g rate limiting/internal backpressure 

419 after_attempt: Callable[[AttemptOutcome], Awaitable[None] | None] = after_attempt_log_failure, # e.g. metrics/logging 

420 on_retryable_error: Callable[[AttemptOutcome], Awaitable[None] | None] = noop, # e.g. count RetryableError failures 

421 on_exhaustion: Callable[[AttemptOutcome], _T | Awaitable[_T]] = on_exhaustion_raise, # raise error or return fallback 

422 log: logging.Logger | None = None, # set this to ``None`` to disable logging 

423) -> _T: 

424 """Async version of call_with_retries() with the same semantics except it awaits ``fn``, awaitable ``before_attempt`` / 

425 ``after_attempt`` / ``on_retryable_error`` / ``on_exhaustion`` / ``giveup`` results and non-blocking sleep. Note that 

426 ``backoff``, and ``RetryTiming.is_terminated`` are not async as they are intentionally fast **synchronous** decisions; 

427 they must not block.""" 

428 rng: random.Random | None = None 

429 retry_count: int = 0 

430 idle_nanos: int = 0 

431 curr_max_sleep_nanos: int = policy.initial_max_sleep_nanos 

432 previous_outcomes: tuple[AttemptOutcome, ...] = () # for safety pass *immutable* deque to callbacks 

433 timing: RetryTiming = policy.timing 

434 sleep: Callable[[int, Retry], Awaitable[None]] = timing.sleep_async 

435 monotonic_ns: Callable[[], int] = timing.monotonic_ns 

436 call_start_nanos: Final[int] = monotonic_ns() 

437 while True: 

438 before_attempt_nanos: int = monotonic_ns() if retry_count != 0 else call_start_nanos 

439 prev: tuple[AttemptOutcome, ...] = previous_outcomes 

440 retry: Retry = Retry( 

441 retry_count, call_start_nanos, before_attempt_nanos, before_attempt_nanos, idle_nanos, policy, log, prev 

442 ) 

443 try: 

444 if before_attempt is not before_attempt_noop: 

445 before_attempt_sleep_nanos: int = await _await_result(before_attempt(retry)) 

446 assert before_attempt_sleep_nanos >= 0, before_attempt_sleep_nanos 

447 if before_attempt_sleep_nanos > 0: 

448 await sleep(before_attempt_sleep_nanos, retry) # e.g. wait due to rate limiting or internal backpressure 

449 attempt_start_nanos: int = monotonic_ns() 

450 idle_nanos += attempt_start_nanos - before_attempt_nanos 

451 retry = Retry( 

452 retry_count, call_start_nanos, before_attempt_nanos, attempt_start_nanos, idle_nanos, policy, log, prev 

453 ) 

454 timing.on_before_attempt(retry) 

455 result: _T = await fn(retry) # Call the target function and supply retry attempt number and other metadata 

456 except RetryableError as retryable_error: 

457 elapsed_nanos: int = monotonic_ns() - call_start_nanos 

458 is_terminated: Callable[[Retry], bool] = timing.is_terminated 

459 giveup_reason: object | None = None 

460 sleep_nanos: int = 0 

461 outcome = AttemptOutcome(retry, False, False, False, None, elapsed_nanos, sleep_nanos, retryable_error) 

462 await _await_result(on_retryable_error(outcome)) # e.g. count RetryableError failures 

463 if retry_count < policy.max_retries and elapsed_nanos < policy.max_elapsed_nanos: 

464 if policy.max_sleep_nanos == 0 and backoff is full_jitter_backoff_strategy: 

465 pass # perf: e.g. spin-before-block 

466 elif retry_count == 0 and retryable_error.retry_immediately_once: 

467 pass # retry once immediately without backoff 

468 else: # jitter: default backoff strategy picks random sleep_nanos in [min_sleep_nanos, curr_max_sleep_nanos] 

469 rng = _thread_local_rng() if rng is None else rng 

470 sleep_nanos, curr_max_sleep_nanos = backoff( # compute delay before next retry attempt, after failure 

471 BackoffContext(retry, curr_max_sleep_nanos, rng, elapsed_nanos, retryable_error) 

472 ) 

473 assert sleep_nanos >= 0 and curr_max_sleep_nanos >= 0, (sleep_nanos, curr_max_sleep_nanos) 

474 

475 if sleep_nanos > 0: 

476 outcome = AttemptOutcome(retry, False, False, False, None, elapsed_nanos, sleep_nanos, retryable_error) 

477 if (not is_terminated(retry)) and (giveup_reason := await _await_result(giveup(outcome))) is None: 

478 await _await_result(after_attempt(outcome)) 

479 await sleep(sleep_nanos, retry) 

480 idle_nanos += sleep_nanos 

481 if not is_terminated(retry): 

482 previous_outcomes = _update_previous_outcomes(previous_outcomes, outcome, policy, retry) 

483 del outcome # help gc 

484 retry_count += 1 

485 continue # continue retry loop with next attempt 

486 else: 

487 sleep_nanos = 0 

488 outcome = AttemptOutcome( 

489 retry, False, True, is_terminated(retry), giveup_reason, elapsed_nanos, sleep_nanos, retryable_error 

490 ) 

491 await _await_result(after_attempt(outcome)) 

492 return await _await_result(on_exhaustion(outcome)) # raise error or return fallback value 

493 else: # success 

494 if after_attempt is not after_attempt_log_failure: 

495 elapsed_nanos = monotonic_ns() - call_start_nanos 

496 outcome = AttemptOutcome(retry, True, False, False, None, elapsed_nanos, 0, result) 

497 await _await_result(after_attempt(outcome)) 

498 return result 

499 

500 

501async def _await_result(result: _T | Awaitable[_T]) -> _T: 

502 """Returns a callback result, awaiting it first when it is awaitable.""" 

503 if inspect.isawaitable(result): 

504 return await result 

505 else: 

506 return result 

507 

508 

509def multi_after_attempt(handlers: Iterable[Callable[[AttemptOutcome], None]]) -> Callable[[AttemptOutcome], None]: 

510 """Composes independent ``after_attempt`` handlers into one ``call_with_retries(after_attempt=...)`` callback that 

511 invokes each handler in order; thread-safe.""" 

512 handlers = tuple(handlers) 

513 if len(handlers) == 1: 

514 return handlers[0] # perf 

515 

516 def _after_attempt(outcome: AttemptOutcome) -> None: 

517 for handler in handlers: 

518 handler(outcome) 

519 

520 return _after_attempt 

521 

522 

523def multi_after_attempt_async( 

524 handlers: Iterable[Callable[[AttemptOutcome], Awaitable[None] | None]], 

525) -> Callable[[AttemptOutcome], Awaitable[None]]: 

526 """Async version of ``multi_after_attempt()``.""" 

527 handlers = tuple(handlers) 

528 

529 async def _after_attempt_async(outcome: AttemptOutcome) -> None: 

530 for handler in handlers: 

531 await _await_result(handler(outcome)) 

532 

533 return _after_attempt_async 

534 

535 

536def any_giveup(handlers: Iterable[Callable[[AttemptOutcome], object | None]]) -> Callable[[AttemptOutcome], object | None]: 

537 """Composes independent ``giveup`` handlers into one ``call_with_retries(giveup=...)`` callback that gives up retrying if 

538 *any* handler gives up; that is if any handler returns a non-``None`` reason; thread-safe. 

539 

540 Handlers are evaluated in order and short-circuit: On giving up returns the first handler's reason for giving up. 

541 """ 

542 handlers = tuple(handlers) 

543 if len(handlers) == 1: 

544 return handlers[0] # perf 

545 

546 def _giveup(outcome: AttemptOutcome) -> object | None: 

547 for handler in handlers: 

548 giveup_reason: object | None = handler(outcome) 

549 if giveup_reason is not None: 

550 return giveup_reason 

551 return None # don't give up retrying 

552 

553 return _giveup 

554 

555 

556def all_giveup(handlers: Iterable[Callable[[AttemptOutcome], object | None]]) -> Callable[[AttemptOutcome], object | None]: 

557 """Composes independent ``giveup`` handlers into one ``call_with_retries(giveup=...)`` callback that gives up retrying if 

558 *all* handlers give up; that is if all handlers return a non-``None`` reason; thread-safe. 

559 

560 Handlers are evaluated in order and short-circuit: stops at first ``None``; else returns the last non-``None`` reason. 

561 """ 

562 handlers = tuple(handlers) 

563 if len(handlers) == 1: 

564 return handlers[0] # perf 

565 

566 def _giveup(outcome: AttemptOutcome) -> object | None: 

567 giveup_reason: object | None = None 

568 for handler in handlers: 

569 giveup_reason = handler(outcome) 

570 if giveup_reason is None: 

571 return None # don't give up retrying 

572 return giveup_reason 

573 

574 return _giveup 

575 

576 

577############################################################################# 

578class RetryableError(Exception): 

579 """Indicates that the task that caused the underlying exception can be retried and might eventually succeed; 

580 ``call_with_retries()`` will pass this exception to callbacks via ``AttemptOutcome.result``; can be subclassed.""" 

581 

582 def __init__( 

583 self, 

584 *exc_args: object, # optional args passed into super().__init__() 

585 display_msg: object = None, # for logging 

586 retry_immediately_once: bool = False, # retry once immediately without backoff? 

587 category: object = None, # optional classification e.g. "CONCURRENCY, "SERVER_ISSUE", "THROTTLING", "TRANSIENT", ... 

588 attachment: object = None, # optional domain specific info passed to next attempt via Retry.previous_outcomes if 

589 # RetryPolicy.max_previous_outcomes > 0. This helps when retrying is not just 'try again later', but 

590 # 'try again differently based on what just happened'. 

591 # Examples: switching network endpoints, adjusting per-attempt timeouts, capping retries by error-class, resuming 

592 # with a token/offset, maintaining failure history for this invocation of call_with_retries(). 

593 # Example: 'cap retries to 3 for ECONNREFUSED but 12 for ETIMEDOUT' via attachment=collections.Counter 

594 ) -> None: 

595 super().__init__(*exc_args) 

596 self.display_msg: object = display_msg 

597 self.retry_immediately_once: bool = retry_immediately_once 

598 self.category: object = category 

599 self.attachment: object = attachment 

600 

601 def display_msg_str(self) -> str: 

602 """Returns the display_msg as a str; for logging.""" 

603 return "" if self.display_msg is None else str(self.display_msg) 

604 

605 

606############################################################################# 

607@final 

608class RetryError(Exception): 

609 """Indicates that retries have been exhausted; the last RetryableError is in RetryError.__cause__.""" 

610 

611 outcome: Final[AttemptOutcome] 

612 """Metadata that describes why and how call_with_retries() gave up.""" 

613 

614 def __init__(self, outcome: AttemptOutcome) -> None: 

615 super().__init__(outcome) 

616 self.outcome = outcome 

617 

618 

619############################################################################# 

620@final 

621class Retry(NamedTuple): 

622 """Attempt metadata provided to callback functions; includes the current retry attempt number; immutable.""" 

623 

624 count: int # type: ignore[assignment] 

625 """Attempt number; count=0 is the first attempt, count=1 is the second attempt aka first retry.""" 

626 

627 call_start_time_nanos: int 

628 """Value of time.monotonic_ns() at start of call_with_retries() invocation.""" 

629 

630 before_attempt_start_time_nanos: int 

631 """Value of time.monotonic_ns() at start of before_attempt() invocation.""" 

632 

633 attempt_start_time_nanos: int 

634 """Value of time.monotonic_ns() at start of fn() invocation.""" 

635 

636 idle_nanos: int 

637 """Sum of all before_attempt_sleep_nanos() plus AttemptOutcome.sleep_nanos across this call_with_retries() invocation, at 

638 the start of fn() invocation.""" 

639 

640 policy: RetryPolicy 

641 """Policy that was passed into call_with_retries().""" 

642 

643 log: logging.Logger | None 

644 """Logger that was passed into call_with_retries().""" 

645 

646 previous_outcomes: Sequence[AttemptOutcome] 

647 """History/state of the N=max_previous_outcomes most recent outcomes for the current call_with_retries() invocation.""" 

648 

649 def copy(self, **override_kwargs: Any) -> Retry: 

650 """Creates a new object copying an existing one with the specified fields overridden for customization.""" 

651 return self._replace(**override_kwargs) 

652 

653 def before_attempt_sleep_nanos(self) -> int: 

654 """Returns duration between the start of before_attempt() and the start of fn() attempt.""" 

655 return self.attempt_start_time_nanos - self.before_attempt_start_time_nanos 

656 

657 def __repr__(self) -> str: 

658 return ( 

659 f"{type(self).__name__}(count={self.count!r}, call_start_time_nanos={self.call_start_time_nanos!r}, " 

660 f"before_attempt_start_time_nanos={self.before_attempt_start_time_nanos!r}, " 

661 f"attempt_start_time_nanos={self.attempt_start_time_nanos!r}, idle_nanos={self.idle_nanos!r})" 

662 ) 

663 

664 def __eq__(self, other: object) -> bool: 

665 return self is other 

666 

667 def __ne__(self, other: object) -> bool: 

668 return self is not other 

669 

670 def __hash__(self) -> int: 

671 return object.__hash__(self) 

672 

673 

674############################################################################# 

675@final 

676class AttemptOutcome(NamedTuple): 

677 """Captures per-attempt state for ``after_attempt`` callbacks; immutable.""" 

678 

679 retry: Retry 

680 """Attempt metadata passed into fn(retry).""" 

681 

682 is_success: bool 

683 """False if fn(retry) raised a RetryableError; True otherwise.""" 

684 

685 is_exhausted: bool 

686 """True if the loop is giving up retrying (possibly even due to is_terminated); False otherwise.""" 

687 

688 is_terminated: bool 

689 """True if the termination predicate has become true; False otherwise.""" 

690 

691 giveup_reason: object | None 

692 """Reason returned by giveup(); None means giveup() was not called or decided to not give up.""" 

693 

694 elapsed_nanos: int 

695 """Total duration between the start of call_with_retries() invocation and the end of this fn() attempt.""" 

696 

697 sleep_nanos: int 

698 """Duration of current sleep period.""" 

699 

700 result: RetryableError | object 

701 """Result of fn(retry); a RetryableError on retryable failure, or some other object on success.""" 

702 

703 def attempt_elapsed_nanos(self) -> int: 

704 """Returns duration between the start of this fn() attempt and the end of this fn() attempt.""" 

705 return self.elapsed_nanos + self.retry.call_start_time_nanos - self.retry.attempt_start_time_nanos 

706 

707 def copy(self, **override_kwargs: Any) -> AttemptOutcome: 

708 """Creates a new outcome copying an existing one with the specified fields overridden for customization.""" 

709 return self._replace(**override_kwargs) 

710 

711 def __repr__(self) -> str: 

712 return ( 

713 f"{type(self).__name__}(" 

714 f"retry={self.retry!r}, " 

715 f"is_success={self.is_success!r}, " 

716 f"is_exhausted={self.is_exhausted!r}, " 

717 f"is_terminated={self.is_terminated!r}, " 

718 f"giveup_reason={self.giveup_reason!r}, " 

719 f"elapsed_nanos={self.elapsed_nanos!r}, " 

720 f"sleep_nanos={self.sleep_nanos!r})" 

721 ) 

722 

723 def __eq__(self, other: object) -> bool: 

724 return self is other 

725 

726 def __ne__(self, other: object) -> bool: 

727 return self is not other 

728 

729 def __hash__(self) -> int: 

730 return object.__hash__(self) 

731 

732 

733############################################################################# 

734@final 

735class BackoffContext(NamedTuple): 

736 """Captures per-backoff state for ``backoff`` callbacks.""" 

737 

738 retry: Retry 

739 """Attempt metadata passed into fn(retry).""" 

740 

741 curr_max_sleep_nanos: int 

742 """Current maximum duration (in nanoseconds) to sleep before the next retry attempt; 

743 Typically: ``RetryPolicy.initial_max_sleep_nanos <= curr_max_sleep_nanos <= RetryPolicy.max_sleep_nanos``.""" 

744 

745 rng: random.Random 

746 """Thread-local random number generator instance.""" 

747 

748 elapsed_nanos: int 

749 """Total duration between the start of call_with_retries() invocation and the end of this fn() attempt.""" 

750 

751 retryable_error: RetryableError 

752 """Result of failed fn(retry) attempt.""" 

753 

754 def copy(self, **override_kwargs: Any) -> BackoffContext: 

755 """Creates a new object copying an existing one with the specified fields overridden for customization.""" 

756 return self._replace(**override_kwargs) 

757 

758 def __repr__(self) -> str: 

759 return ( 

760 f"{type(self).__name__}(" 

761 f"retry={self.retry!r}, " 

762 f"curr_max_sleep_nanos={self.curr_max_sleep_nanos!r}, " 

763 f"elapsed_nanos={self.elapsed_nanos!r})" 

764 ) 

765 

766 def __eq__(self, other: object) -> bool: 

767 return self is other 

768 

769 def __ne__(self, other: object) -> bool: 

770 return self is not other 

771 

772 def __hash__(self) -> int: 

773 return object.__hash__(self) 

774 

775 

776BackoffStrategy = Callable[[BackoffContext], tuple[int, int]] # typealias; returns sleep_nanos:int, curr_max_sleep_nanos:int 

777"""Strategy that implements a backoff algorithm that reduces server load while minimizing retry latency; default is full 

778jitter; various other example backoff strategies such as decorrelated-jitter or retry-after HTTP 429 "Too Many Requests" 

779responses, etc can be found in test_retry_examples.py.""" 

780 

781 

782############################################################################# 

783def _default_timing_is_terminated(retry: Retry) -> bool: 

784 return False 

785 

786 

787def _default_timing_sleep(sleep_nanos: int, retry: Retry) -> None: 

788 time.sleep(sleep_nanos / 1_000_000_000) 

789 

790 

791async def _default_timing_sleep_asyncio(sleep_nanos: int, retry: Retry) -> None: 

792 import asyncio 

793 

794 await asyncio.sleep(sleep_nanos / 1_000_000_000) 

795 

796 

797def _default_timing_on_before_attempt(retry: Retry) -> None: 

798 if retry.policy.timing.is_terminated(retry): 

799 raise RetryableError(display_msg="terminated before attempt") from RetryTerminationError() 

800 

801 

802@final 

803class RetryTerminationError(InterruptedError): 

804 """Termination signal raised when retry loop exits before starting the next attempt.""" 

805 

806 

807@dataclass(frozen=True) 

808@final 

809class RetryTiming: 

810 """Customizable callbacks for reading the current monotonic time, sleeping and optional async termination; immutable.""" 

811 

812 monotonic_ns: Callable[[], int] = time.monotonic_ns 

813 """Returns the system's current monotonic time in nanoseconds.""" 

814 

815 is_terminated: Callable[[Retry], bool] = _default_timing_is_terminated 

816 """Returns whether a predicate has become true; if so causes the retry loop to exit early between attempts; can be used 

817 to indicate system shutdown or similar cancellation conditions; default is to always return ``False``; this function 

818 should complete quickly without any blocking or sleeping.""" 

819 

820 sleep: Callable[[int, Retry], None] = _default_timing_sleep 

821 """Sleeps N nanoseconds between attempts; override to inject custom sleeping or for early wake-ups; thread-safe.""" 

822 

823 sleep_async: Callable[[int, Retry], Awaitable[None]] = _default_timing_sleep_asyncio 

824 """Sleeps N nanoseconds between attempts; override to inject custom sleeping or for early wake-ups; thread-safe.""" 

825 

826 on_before_attempt: Callable[[Retry], None] = _default_timing_on_before_attempt 

827 """Typically (but not necessarily) raises an error if ``is_terminated()`` is True; otherwise fn() will still run; this 

828 function should complete quickly without any blocking or sleeping. 

829 

830 To disable this behavior: RetryTiming.make_from(...).copy(on_before_attempt=lambda retry: None). 

831 """ 

832 

833 def copy(self, **override_kwargs: Any) -> RetryTiming: 

834 """Creates a new object copying an existing one with the specified fields overridden for customization; thread- 

835 safe.""" 

836 return dataclasses.replace(self, **override_kwargs) 

837 

838 @staticmethod 

839 def make_from(termination_event: threading.Event | None) -> RetryTiming: 

840 """Convenience factory that creates a RetryTiming that performs async termination when termination_event is set.""" 

841 if termination_event is None: 

842 return RetryTiming() 

843 

844 def _is_terminated(retry: Retry) -> bool: 

845 return termination_event.is_set() 

846 

847 def _sleep(sleep_nanos: int, retry: Retry) -> None: 

848 termination_event.wait(sleep_nanos / 1_000_000_000) # allow early wakeup on async termination 

849 

850 return RetryTiming(is_terminated=_is_terminated, sleep=_sleep) 

851 

852 @staticmethod 

853 def make_from_asyncio(termination_event: asyncio.Event | None) -> RetryTiming: 

854 """Convenience factory that creates a RetryTiming that performs async termination when termination_event is set; 

855 Write an analog version of this function if you wish to replace asyncio with Trio or AnyIO or similar.""" 

856 if termination_event is None: 

857 return RetryTiming() 

858 

859 def _is_terminated(retry: Retry) -> bool: 

860 return termination_event.is_set() 

861 

862 async def _sleep_async(sleep_nanos: int, retry: Retry) -> None: 

863 import asyncio 

864 

865 if sleep_nanos <= 0: 

866 await asyncio.sleep(0) # perf: cooperative yield with less overhead than asyncio.wait_for(..., 0) 

867 return 

868 try: 

869 await asyncio.wait_for(termination_event.wait(), timeout=sleep_nanos / 1_000_000_000) 

870 except asyncio.TimeoutError: 

871 pass # expected 

872 

873 return RetryTiming(is_terminated=_is_terminated, sleep_async=_sleep_async) 

874 

875 

876############################################################################# 

877def _format_msg(display_msg: str, retryable_error: RetryableError) -> str: 

878 """Default implementation of ``format_msg`` callback for RetryConfig; creates simple log message; thread-safe.""" 

879 msg = display_msg + " " if display_msg else "" 

880 errmsg: str = retryable_error.display_msg_str() 

881 msg = msg + errmsg + " " if errmsg else msg 

882 msg = msg if msg else "Retrying " 

883 return msg 

884 

885 

886def _format_pair(first: object, second: object) -> str: 

887 """Default implementation of ``format_pair`` callback for RetryConfig; creates simple log message part; thread-safe.""" 

888 second = "∞" if INFINITY_MAX_RETRIES == second else second # noqa: SIM300 

889 return f"[{first}/{second}]" 

890 

891 

892@dataclass(frozen=True) 

893@final 

894class RetryConfig: 

895 """Configures logging for call_with_retries(); all defaults work out of the box; immutable.""" 

896 

897 display_msg: str = "Retrying" # message prefix for retry log messages 

898 dots: str = " ..." # suffix appended to retry log messages 

899 format_msg: Callable[[str, RetryableError], str] = _format_msg # lambda: display_msg, retryable_error 

900 format_pair: Callable[[object, object], str] = _format_pair # lambda: first, second 

901 format_duration: Callable[[int], str] = human_readable_duration # lambda: nanos 

902 info_loglevel: int = logging.INFO # loglevel used when not giving up 

903 warning_loglevel: int = logging.WARNING # loglevel used when giving up 

904 enable_logging: bool = True # set to False to disable logging 

905 exc_info: bool = False # passed into Logger.log() 

906 stack_info: bool = False # passed into Logger.log() 

907 extra: Mapping[str, object] | None = dataclasses.field(default=None, repr=False, compare=False) # passed to Logger.log() 

908 context: object = dataclasses.field(default=None, repr=False, compare=False) # optional domain specific info 

909 

910 def copy(self, **override_kwargs: Any) -> RetryConfig: 

911 """Creates a new config copying an existing one with the specified fields overridden for customization.""" 

912 return dataclasses.replace(self, **override_kwargs) 

913 

914 

915############################################################################# 

916@dataclass(frozen=True) 

917@final 

918class RetryPolicy: 

919 """Configuration of maximum retries, sleep bounds, elapsed-time budget, logging, etc for call_with_retries(); immutable. 

920 

921 By default uses full jitter which works as follows: The maximum duration to sleep between attempts initially starts with 

922 ``initial_max_sleep_secs`` and doubles on each retry, up to the final maximum of ``max_sleep_secs``. 

923 Example: 125ms --> 250ms --> 500ms --> 1s --> 2s --> 4s --> 8s --> 10s --> 10s... 

924 On each retry a random sleep duration in the range ``[min_sleep_secs, current max]`` is picked. 

925 In a nutshell: ``0 <= min_sleep_secs <= initial_max_sleep_secs <= max_sleep_secs``. Typically, min_sleep_secs=0. 

926 """ 

927 

928 max_retries: int = INFINITY_MAX_RETRIES 

929 """The maximum number of times ``fn`` will be invoked additionally after the first attempt invocation; must be >= 0.""" 

930 

931 min_sleep_secs: float = 0 

932 """The minimum duration to sleep between any two attempts.""" 

933 

934 initial_max_sleep_secs: float = 0.125 

935 """The initial maximum duration to sleep between any two attempts.""" 

936 

937 max_sleep_secs: float = 10 

938 """The final max duration to sleep between any two attempts; 0 <= min_sleep_secs <= initial_max_sleep_secs <= 

939 max_sleep_secs.""" 

940 

941 max_elapsed_secs: float = 47 

942 """``fn`` will not be retried (or not retried anymore) once this much time has elapsed since the initial start of 

943 call_with_retries(); set this to 365 * 86400 seconds or similar to effectively disable the time limit.""" 

944 

945 exponential_base: float = 2 

946 """Growth factor (aka multiplier) for backoff algorithm to calculate sleep duration; must be >= 1.""" 

947 

948 max_elapsed_nanos: int = dataclasses.field(init=False, repr=False) # derived value 

949 min_sleep_nanos: int = dataclasses.field(init=False, repr=False) # derived value 

950 initial_max_sleep_nanos: int = dataclasses.field(init=False, repr=False) # derived value 

951 max_sleep_nanos: int = dataclasses.field(init=False, repr=False) # derived value 

952 

953 reraise: bool = True 

954 """On exhaustion, the default (``True``) is to re-raise the underlying exception when present.""" 

955 

956 max_previous_outcomes: int = 0 

957 """Pass the N=max_previous_outcomes most recent AttemptOutcome objects to callbacks via Retry.previous_outcomes.""" 

958 

959 config: RetryConfig = dataclasses.field(default=RetryConfig(), repr=False, compare=False) 

960 """Configures logging behavior.""" 

961 

962 timing: RetryTiming = dataclasses.field(default=RetryTiming(), repr=False) 

963 """Customizable callbacks for reading the current monotonic time, sleeping and optional async termination.""" 

964 

965 context: object = dataclasses.field(default=None, repr=False, compare=False) 

966 """Optional domain specific info.""" 

967 

968 @classmethod 

969 def from_namespace(cls, args: argparse.Namespace) -> RetryPolicy: 

970 """Factory that reads the policy from argparse.ArgumentParser via args.""" 

971 return cls( 

972 max_retries=getattr(args, "max_retries", INFINITY_MAX_RETRIES), 

973 min_sleep_secs=getattr(args, "retry_min_sleep_secs", 0), 

974 initial_max_sleep_secs=getattr(args, "retry_initial_max_sleep_secs", 0.125), 

975 max_sleep_secs=getattr(args, "retry_max_sleep_secs", 10), 

976 max_elapsed_secs=getattr(args, "retry_max_elapsed_secs", 47), 

977 exponential_base=getattr(args, "retry_exponential_base", 2), 

978 reraise=getattr(args, "retry_reraise", True), 

979 max_previous_outcomes=getattr(args, "retry_max_previous_outcomes", 0), 

980 config=getattr(args, "retry_config", RetryConfig()), 

981 timing=getattr(args, "retry_timing", RetryTiming()), 

982 context=getattr(args, "retry_context", None), 

983 ) 

984 

985 @classmethod 

986 def no_retries(cls) -> RetryPolicy: 

987 """Returns a policy that never retries.""" 

988 return cls( 

989 max_retries=0, 

990 min_sleep_secs=0, 

991 initial_max_sleep_secs=0, 

992 max_sleep_secs=0, 

993 max_elapsed_secs=0, 

994 ) 

995 

996 def __post_init__(self) -> None: # validate and compute derived values 

997 self._validate_min("max_retries", self.max_retries, 0) 

998 self._validate_min("exponential_base", self.exponential_base, 1) 

999 self._validate_min("min_sleep_secs", self.min_sleep_secs, 0) 

1000 self._validate_min("initial_max_sleep_secs", self.initial_max_sleep_secs, 0) 

1001 self._validate_min("max_sleep_secs", self.max_sleep_secs, 0) 

1002 self._validate_min("max_elapsed_secs", self.max_elapsed_secs, 0) 

1003 object.__setattr__(self, "max_elapsed_nanos", int(self.max_elapsed_secs * 1_000_000_000)) # derived value 

1004 min_sleep_nanos: int = int(self.min_sleep_secs * 1_000_000_000) 

1005 initial_max_sleep_nanos: int = int(self.initial_max_sleep_secs * 1_000_000_000) 

1006 max_sleep_nanos: int = int(self.max_sleep_secs * 1_000_000_000) 

1007 max_sleep_nanos = max(min_sleep_nanos, max_sleep_nanos) 

1008 initial_max_sleep_nanos = min(max_sleep_nanos, max(min_sleep_nanos, initial_max_sleep_nanos)) 

1009 object.__setattr__(self, "min_sleep_nanos", min_sleep_nanos) # derived value 

1010 object.__setattr__(self, "initial_max_sleep_nanos", initial_max_sleep_nanos) # derived value 

1011 object.__setattr__(self, "max_sleep_nanos", max_sleep_nanos) # derived value 

1012 self._validate_min("max_previous_outcomes", self.max_previous_outcomes, 0) 

1013 assert 0 <= self.min_sleep_nanos <= self.initial_max_sleep_nanos <= self.max_sleep_nanos 

1014 if not isinstance(self.reraise, bool): 

1015 raise TypeError(f"{type(self).__name__}.reraise must be bool") 

1016 

1017 def _validate_min(self, attr_name: str, value: float, minimum: float) -> None: 

1018 if value < minimum: 

1019 raise ValueError(f"Invalid {type(self).__name__}.{attr_name}: must be >= {minimum} but got {value}") 

1020 

1021 def copy(self, **override_kwargs: Any) -> RetryPolicy: 

1022 """Creates a new policy copying an existing one with the specified fields overridden for customization; thread-safe. 

1023 

1024 Example usage: policy = retry_policy.copy(max_sleep_secs=2, max_elapsed_secs=10) 

1025 """ 

1026 return dataclasses.replace(self, **override_kwargs) 

1027 

1028 

1029############################################################################# 

1030def _fn_not_implemented(_retry: Retry) -> NoReturn: 

1031 """Default implementation of ``fn`` callback for RetryTemplate; always raises.""" 

1032 raise NotImplementedError("Provide fn when calling RetryTemplate") 

1033 

1034 

1035NO_LOGGER: Final[logging.Logger] = logging.Logger("NULL") # noqa: LOG001 do not register dummy logger with Logger.manager 

1036NO_LOGGER.addHandler(logging.NullHandler()) # prevents lastResort fallback 

1037NO_LOGGER.disabled = True 

1038NO_LOGGER.propagate = False 

1039_R = TypeVar("_R") 

1040 

1041 

1042@dataclass(frozen=True) 

1043@final 

1044class RetryTemplate(Generic[_T]): 

1045 """Convenience class that aggregates all knobs for call_with_retries(); and is itself callable too; immutable.""" 

1046 

1047 fn: Callable[[Retry], _T] = _fn_not_implemented # set this to make the RetryTemplate object itself callable 

1048 policy: RetryPolicy = RetryPolicy() # specifies how ``RetryableError`` shall be retried 

1049 backoff: BackoffStrategy = full_jitter_backoff_strategy # computes delay time before next retry attempt, after failure 

1050 giveup: Callable[[AttemptOutcome], object | None] = no_giveup # stop retrying based on domain-specific logic, e.g. time 

1051 before_attempt: Callable[[Retry], int] = before_attempt_noop # e.g. wait due to rate limiting or internal backpressure 

1052 after_attempt: Callable[[AttemptOutcome], None] = after_attempt_log_failure # e.g. record metrics and/or custom logging 

1053 on_retryable_error: Callable[[AttemptOutcome], None] = noop # e.g. count failures (RetryableError) caught by retry loop 

1054 on_exhaustion: Callable[[AttemptOutcome], _T] = on_exhaustion_raise # raise error or return fallback value 

1055 log: logging.Logger | None = None # set this to ``None`` to disable logging 

1056 

1057 def copy(self, **override_kwargs: Any) -> RetryTemplate[_T]: 

1058 """Creates a new object copying an existing one with the specified fields overridden for customization; thread-safe. 

1059 

1060 Example usage: retry_template.copy(policy=policy.copy(max_sleep_secs=2, max_elapsed_secs=10), log=None) 

1061 """ 

1062 return dataclasses.replace(self, **override_kwargs) 

1063 

1064 def __call__(self) -> _T: 

1065 """Invokes ``self.fn`` via the call_with_retries() retry loop using the stored parameters; thread-safe. 

1066 

1067 Example Usage: result: str = retry_template.copy(fn=...)() 

1068 """ 

1069 return call_with_retries( 

1070 fn=self.fn, 

1071 policy=self.policy, 

1072 backoff=self.backoff, 

1073 giveup=self.giveup, 

1074 before_attempt=self.before_attempt, 

1075 after_attempt=self.after_attempt, 

1076 on_retryable_error=self.on_retryable_error, 

1077 on_exhaustion=self.on_exhaustion, 

1078 log=self.log, 

1079 ) 

1080 

1081 def call_with_retries( 

1082 self, 

1083 fn: Callable[[Retry], _R], 

1084 policy: RetryPolicy | None = None, 

1085 *, 

1086 backoff: BackoffStrategy | None = None, 

1087 giveup: Callable[[AttemptOutcome], object | None] | None = None, 

1088 before_attempt: Callable[[Retry], int] | None = None, 

1089 after_attempt: Callable[[AttemptOutcome], None] | None = None, 

1090 on_retryable_error: Callable[[AttemptOutcome], None] | None = None, 

1091 on_exhaustion: Callable[[AttemptOutcome], _R] | None = None, 

1092 log: logging.Logger | None = None, # pass NO_LOGGER to override template logger and disable logging for this call 

1093 ) -> _R: 

1094 """Invokes ``fn`` via the call_with_retries() retry loop using the stored or overridden params; thread-safe. 

1095 

1096 Example Usage: result: str = retry_template.call_with_retries(fn=...) 

1097 """ 

1098 return call_with_retries( 

1099 fn=fn, 

1100 policy=self.policy if policy is None else policy, 

1101 backoff=self.backoff if backoff is None else backoff, 

1102 giveup=self.giveup if giveup is None else giveup, 

1103 before_attempt=self.before_attempt if before_attempt is None else before_attempt, 

1104 after_attempt=self.after_attempt if after_attempt is None else after_attempt, 

1105 on_retryable_error=self.on_retryable_error if on_retryable_error is None else on_retryable_error, 

1106 on_exhaustion=( 

1107 cast(Callable[[AttemptOutcome], _R], self.on_exhaustion) if on_exhaustion is None else on_exhaustion 

1108 ), 

1109 log=None if log is NO_LOGGER else self.log if log is None else log, 

1110 ) 

1111 

1112 def wraps(self, fn: Callable[..., _R]) -> Callable[..., _R]: 

1113 """Returns a wrapper function that forwards all arguments to ``fn`` and retries it using this template; thread-safe. 

1114 

1115 Example Usage: 

1116 def fn(x: int) -> int: 

1117 return x * 2 

1118 func: Callable[[int], int] = retry_template.wraps(fn) 

1119 y: int = func(5) # returns 10 

1120 """ 

1121 

1122 @functools.wraps(fn) 

1123 def wrapped(*args: Any, **kwargs: Any) -> _R: 

1124 return self.call_with_retries(fn=lambda _retry: fn(*args, **kwargs)) 

1125 

1126 return wrapped 

1127 

1128 

1129############################################################################# 

1130@dataclass(frozen=True) 

1131@final 

1132class AsyncRetryTemplate(Generic[_T]): 

1133 """Async version of ``RetryTemplate`` with the same semantics except it awaits ``fn``, awaitable ``before_attempt`` / 

1134 ``after_attempt`` / ``on_retryable_error`` / ``on_exhaustion`` / ``giveup`` results and non-blocking sleep. Note that 

1135 ``backoff``, and ``RetryTiming.is_terminated`` are not async as they are intentionally fast **synchronous** decisions; 

1136 they must not block.""" 

1137 

1138 fn: Callable[[Retry], Awaitable[_T]] = _fn_not_implemented # set this to make the RetryTemplate object itself callable 

1139 policy: RetryPolicy = RetryPolicy() # specifies how ``RetryableError`` shall be retried 

1140 backoff: BackoffStrategy = full_jitter_backoff_strategy # computes delay time before next retry attempt, after failure 

1141 giveup: Callable[[AttemptOutcome], object | Awaitable] = no_giveup # stop retrying based on custom logic, e.g. time 

1142 before_attempt: Callable[[Retry], int | Awaitable[int]] = before_attempt_noop # e.g. rate limiting/internal backpressure 

1143 after_attempt: Callable[[AttemptOutcome], Awaitable[None] | None] = after_attempt_log_failure # e.g. metrics/logging 

1144 on_retryable_error: Callable[[AttemptOutcome], Awaitable[None] | None] = noop # e.g. count RetryableError failures 

1145 on_exhaustion: Callable[[AttemptOutcome], _T | Awaitable[_T]] = on_exhaustion_raise # raise or fallback 

1146 log: logging.Logger | None = None # set this to ``None`` to disable logging 

1147 

1148 def copy(self, **override_kwargs: Any) -> AsyncRetryTemplate[_T]: 

1149 """Creates a new object copying an existing one with the specified fields overridden for customization; thread-safe. 

1150 

1151 Example usage: retry_template.copy(policy=policy.copy(max_sleep_secs=2, max_elapsed_secs=10), log=None) 

1152 """ 

1153 return dataclasses.replace(self, **override_kwargs) 

1154 

1155 async def __call__(self) -> _T: 

1156 """Invokes ``self.fn`` via the call_with_retries_async() retry loop using the stored parameters; thread-safe. 

1157 

1158 Example Usage: result: str = await retry_template.copy(fn=...)() 

1159 """ 

1160 return await call_with_retries_async( 

1161 fn=self.fn, 

1162 policy=self.policy, 

1163 backoff=self.backoff, 

1164 giveup=self.giveup, 

1165 before_attempt=self.before_attempt, 

1166 after_attempt=self.after_attempt, 

1167 on_retryable_error=self.on_retryable_error, 

1168 on_exhaustion=self.on_exhaustion, 

1169 log=self.log, 

1170 ) 

1171 

1172 async def call_with_retries( 

1173 self, 

1174 fn: Callable[[Retry], Awaitable[_R]], 

1175 policy: RetryPolicy | None = None, 

1176 *, 

1177 backoff: BackoffStrategy | None = None, 

1178 giveup: Callable[[AttemptOutcome], object | Awaitable] | None = None, 

1179 before_attempt: Callable[[Retry], int | Awaitable[int]] | None = None, 

1180 after_attempt: Callable[[AttemptOutcome], Awaitable[None] | None] | None = None, 

1181 on_retryable_error: Callable[[AttemptOutcome], Awaitable[None] | None] | None = None, 

1182 on_exhaustion: Callable[[AttemptOutcome], _R | Awaitable[_R]] | None = None, 

1183 log: logging.Logger | None = None, # pass NO_LOGGER to override template logger and disable logging for this call 

1184 ) -> _R: 

1185 """Invokes ``fn`` via the call_with_retries_async() retry loop using the stored or overridden params; thread-safe. 

1186 

1187 Example Usage: result: str = await retry_template.call_with_retries(fn=...) 

1188 """ 

1189 return await call_with_retries_async( 

1190 fn=fn, 

1191 policy=self.policy if policy is None else policy, 

1192 backoff=self.backoff if backoff is None else backoff, 

1193 giveup=self.giveup if giveup is None else giveup, 

1194 before_attempt=self.before_attempt if before_attempt is None else before_attempt, 

1195 after_attempt=self.after_attempt if after_attempt is None else after_attempt, 

1196 on_retryable_error=self.on_retryable_error if on_retryable_error is None else on_retryable_error, 

1197 on_exhaustion=( 

1198 cast(Callable[[AttemptOutcome], Union[_R, Awaitable[_R]]], self.on_exhaustion) 

1199 if on_exhaustion is None 

1200 else on_exhaustion 

1201 ), 

1202 log=None if log is NO_LOGGER else self.log if log is None else log, 

1203 ) 

1204 

1205 def wraps(self, fn: Callable[..., Awaitable[_R]]) -> Callable[..., Awaitable[_R]]: 

1206 """Returns a wrapper function that forwards all arguments to ``fn`` and retries it using this template; thread-safe. 

1207 

1208 Example Usage: 

1209 async def fn(x: int) -> int: 

1210 return x * 2 

1211 func: Callable[[int], Awaitable[int]] = retry_template.wraps(fn) 

1212 y: int = await func(5) # returns 10 

1213 """ 

1214 

1215 @functools.wraps(fn) 

1216 async def wrapped_async(*args: Any, **kwargs: Any) -> _R: 

1217 return await self.call_with_retries(fn=lambda _retry: fn(*args, **kwargs)) 

1218 

1219 return wrapped_async 

1220 

1221 

1222############################################################################# 

1223def raise_retryable_error_from( 

1224 exc: BaseException, 

1225 *, 

1226 display_msg: object = None, 

1227 retry_immediately_once: bool = False, 

1228 category: object = None, 

1229 attachment: object = None, 

1230) -> NoReturn: 

1231 """Convenience function that raises a generic RetryableError that wraps the given underlying exception.""" 

1232 raise RetryableError( 

1233 display_msg=type(exc).__name__ if display_msg is None else display_msg, 

1234 retry_immediately_once=retry_immediately_once, 

1235 category=category, 

1236 attachment=attachment, 

1237 ) from exc 

1238 

1239 

1240ExceptionPredicate = Union[bool, Callable[[BaseException], bool]] # Type alias 

1241 

1242 

1243def call_with_exception_handlers( 

1244 fn: Callable[[], _T], # typically a lambda 

1245 *, 

1246 continue_scanning_if_no_predicate_matches: bool = False, 

1247 handlers: Mapping[type[BaseException], Sequence[tuple[ExceptionPredicate, Callable[[BaseException], _T]]]], 

1248) -> _T: 

1249 """Convenience function that calls ``fn`` and returns its result; on exception runs the first matching handler in a per- 

1250 exception handler chain; composes independent handlers via predicates into one function, in Event-Predicate-Action style. 

1251 

1252 Lookup uses the exception type's Method Resolution Order (most-specific class in the exception class hierarchy wins). For 

1253 the first class that exists as a key in ``handlers``, its chain is scanned in order. Each chain element is 

1254 ``(predicate, handler)`` where ``predicate`` is either ``True`` (always matches), ``False`` (disabled), or 

1255 ``predicate(exc) -> bool``. The first matching handler is called with the exception and its return value is returned. If 

1256 no predicate matches then, by default, the original exception is re-raised and no less-specific handler chains are 

1257 consulted. Set ``continue_scanning_if_no_predicate_matches=True`` to continue scanning exception base classes instead. 

1258 

1259 Typically (but not necessarily) the handler raises a ``RetryableError``, via ``raise_retryable_error_from`` or similar. 

1260 Or it may raise another exception type (which will not be retried), or even return a fallback value instead of raising. 

1261 

1262 Example: turn transient ssh/zfs command failures into RetryableError for call_with_retries(), including feature flags: 

1263 

1264 def run_remote(retry: Retry) -> str: 

1265 p = subprocess.run(["ssh", "foo.example.com", "zfs", "list", "-H"], text=True, capture_output=True, check=True) 

1266 return p.stdout 

1267 

1268 def fn(retry: Retry) -> str: 

1269 return call_with_exception_handlers( 

1270 fn=lambda: run_remote(retry), 

1271 handlers={ 

1272 TimeoutError: [(True, raise_retryable_error_from)], 

1273 ConnectionResetError: [(True, lambda exc: raise_retryable_error_from(exc, display_msg="ssh reset"))], 

1274 subprocess.CalledProcessError: [ 

1275 (lambda exc: exc.returncode == 255, lambda exc: raise_retryable_error_from(exc, display_msg="ssh error")), 

1276 (lambda exc: "cannot receive" in (exc.stderr or ""), lambda exc: raise_retryable_error_from(exc, display_msg="zfs recv")), 

1277 ], 

1278 OSError: [ 

1279 (lambda exc: getattr(exc, "errno", None) in {errno.ETIMEDOUT, errno.EHOSTUNREACH}, 

1280 lambda exc: raise_retryable_error_from(exc, display_msg=f"network: {exc}")), 

1281 (False, lambda exc: raise_retryable_error_from(exc, display_msg="disabled handler example")), 

1282 ], 

1283 }, 

1284 ) 

1285 

1286 stdout: str = call_with_retries(fn=fn, policy=RetryPolicy(max_retries=3)) 

1287 

1288 Example: return a fallback value (no retry loop required): 

1289 

1290 def read_optional_file(path: str) -> str: 

1291 return call_with_exception_handlers( 

1292 fn=lambda: open(path, encoding="utf-8").read(), 

1293 handlers={FileNotFoundError: [(True, lambda _exc: "")]}, 

1294 ) 

1295 """ 

1296 try: 

1297 return fn() 

1298 except BaseException as exc: 

1299 for cls in type(exc).__mro__: 

1300 handler_chain = handlers.get(cls) 

1301 if handler_chain is not None: 

1302 for predicate, handler in handler_chain: 

1303 if predicate is True or (predicate is not False and predicate(exc)): 

1304 return handler(exc) 

1305 if not continue_scanning_if_no_predicate_matches: 

1306 raise 

1307 raise 

1308 

1309 

1310############################################################################# 

1311@final 

1312class _ThreadLocalRNG(threading.local): 

1313 """Caches a per-thread random number generator.""" 

1314 

1315 def __init__(self) -> None: 

1316 self.rng: random.Random | None = None 

1317 

1318 

1319_THREAD_LOCAL_RNG: Final[_ThreadLocalRNG] = _ThreadLocalRNG() 

1320 

1321 

1322def _thread_local_rng() -> random.Random: 

1323 """Returns a per-thread RNG for backoff jitter; for perf avoids locking and initializing a new random.Random() at high 

1324 frequency.""" 

1325 threadlocal: _ThreadLocalRNG = _THREAD_LOCAL_RNG 

1326 rng: random.Random | None = threadlocal.rng 

1327 if rng is None: 

1328 rng = random.Random() # noqa: S311 jitter isn't security sensitive, and random.SystemRandom.randint() is slow 

1329 threadlocal.rng = rng 

1330 return rng