from __future__ import annotations import random import time import unittest from collections.abc import Callable from typing import ParamSpec, TypeVar P = ParamSpec("P") T = TypeVar("T") def retry_with_exponential_backoff( func: Callable[P, T], *args: P.args, attempts: int = 3, initial_delay: float = 0.1, multiplier: float = 2.0, max_delay: float = 10.0, jitter: float = 0.0, retry_on: tuple[type[BaseException], ...] = (Exception,), sleep: Callable[[float], None] = time.sleep, **kwargs: P.kwargs, ) -> T: """Call func until it succeeds or retry attempts are exhausted.""" if attempts < 1: raise ValueError("attempts must be at least 1") if initial_delay < 0: raise ValueError("initial_delay must be non-negative") if multiplier < 1: raise ValueError("multiplier must be at least 1") if max_delay < 0: raise ValueError("max_delay must be non-negative") if jitter < 0: raise ValueError("jitter must be non-negative") delay = initial_delay for attempt in range(1, attempts + 1): try: return func(*args, **kwargs) except retry_on: if attempt == attempts: raise # Jitter helps avoid synchronized retries across many callers. actual_delay = delay + random.uniform(0, jitter) if jitter else delay sleep(actual_delay) delay = min(delay * multiplier, max_delay) raise RuntimeError("unreachable") class RetryWithBackoffTests(unittest.TestCase): def test_succeeds_after_transient_failures(self) -> None: calls = 0 sleeps: list[float] = [] def flaky() -> str: nonlocal calls calls += 1 if calls < 3: raise TimeoutError("temporary failure") return "ok" result = retry_with_exponential_backoff( flaky, attempts=4, initial_delay=0.5, multiplier=2.0, sleep=sleeps.append, ) self.assertEqual(result, "ok") self.assertEqual(calls, 3) self.assertEqual(sleeps, [0.5, 1.0]) def test_raises_after_attempts_are_exhausted(self) -> None: sleeps: list[float] = [] def always_fails() -> None: raise ConnectionError("still down") with self.assertRaises(ConnectionError): retry_with_exponential_backoff( always_fails, attempts=3, initial_delay=1.0, multiplier=3.0, max_delay=2.0, sleep=sleeps.append, ) self.assertEqual(sleeps, [1.0, 2.0]) if __name__ == "__main__": unittest.main()