"""Bounded retry with exponential backoff + full jitter. Fixes subtle bug in prior retry: jitter must be uniform over [0, cap] where cap is min(max_delay, base_delay*2**attempt), and a zero/negative sleep must never be passed to a clock that rejects negatives. Also re-raises the ORIGINAL exception instance (with its traceback) rather than a re-wrapped one. """ from __future__ import annotations import random, time from typing import Callable, Optional, Tuple, Type class RetryExhausted(Exception): pass def retry(fn, *, max_attempts=3, base_delay=0.1, max_delay=10.0, retryable=(Exception,), sleep=time.sleep, rng=None): if max_attempts < 1: raise ValueError("max_attempts must be >= 1") if base_delay < 0 or max_delay < 0: raise ValueError("delays must be >= 0") r = rng if rng is not None else random.Random() last = None for attempt in range(max_attempts): try: return fn() except retryable as e: last = e if attempt == max_attempts - 1: break exp = min(max_delay, base_delay * (2 ** attempt)) delay = r.uniform(0.0, exp) if exp > 0 else 0.0 if delay < 0: delay = 0.0 sleep(delay) if last is None: raise RetryExhausted() raise last