from collections.abc import Callable import time from typing import ParamSpec, TypeVar import unittest P = ParamSpec("P") T = TypeVar("T") def retry_with_backoff( function: Callable[P, T], *args: P.args, max_attempts: int = 3, initial_delay: float = 0.5, multiplier: float = 2.0, retry_on: tuple[type[Exception], ...] = (Exception,), sleep: Callable[[float], None] = time.sleep, **kwargs: P.kwargs, ) -> T: """Call a function until it succeeds or the retry limit is reached.""" if max_attempts < 1: raise ValueError("max_attempts must be at least 1") if initial_delay < 0: raise ValueError("initial_delay cannot be negative") if multiplier < 1: raise ValueError("multiplier must be at least 1") delay = initial_delay for attempt in range(1, max_attempts + 1): try: return function(*args, **kwargs) except retry_on: if attempt == max_attempts: raise sleep(delay) delay *= multiplier raise RuntimeError("unreachable") class RetryWithBackoffTests(unittest.TestCase): def test_retries_with_exponential_delays(self) -> None: attempts = 0 delays: list[float] = [] def flaky() -> str: nonlocal attempts attempts += 1 if attempts < 3: raise ValueError("temporary failure") return "ok" result = retry_with_backoff( flaky, max_attempts=3, initial_delay=0.25, multiplier=2, sleep=delays.append, ) self.assertEqual(result, "ok") self.assertEqual(delays, [0.25, 0.5]) def test_reraises_after_final_attempt(self) -> None: delays: list[float] = [] def always_fails() -> None: raise ConnectionError("unavailable") with self.assertRaisesRegex(ConnectionError, "unavailable"): retry_with_backoff( always_fails, max_attempts=2, initial_delay=1, sleep=delays.append, ) self.assertEqual(delays, [1]) if __name__ == "__main__": unittest.main()