from collections.abc import Callable import time from typing import ParamSpec, TypeVar import unittest from unittest.mock import Mock P = ParamSpec("P") T = TypeVar("T") def retry( function: Callable[P, T], *args: P.args, attempts: int = 3, initial_delay: float = 0.5, backoff_factor: float = 2.0, retry_on: tuple[type[BaseException], ...] = (Exception,), sleep: Callable[[float], None] = time.sleep, **kwargs: P.kwargs, ) -> T: """Call a function, retrying failures with exponential backoff.""" if attempts < 1: raise ValueError("attempts must be at least 1") if initial_delay < 0: raise ValueError("initial_delay cannot be negative") if backoff_factor < 0: raise ValueError("backoff_factor cannot be negative") delay = initial_delay for attempt in range(attempts): try: return function(*args, **kwargs) except retry_on: if attempt == attempts - 1: raise sleep(delay) delay *= backoff_factor raise RuntimeError("unreachable") class RetryTests(unittest.TestCase): def test_retries_with_exponential_backoff(self) -> None: sleep = Mock() outcomes: list[Exception | str] = [ RuntimeError("first"), RuntimeError("second"), "success", ] def flaky() -> str: result = outcomes.pop(0) if isinstance(result, Exception): raise result return result self.assertEqual( retry(flaky, attempts=3, initial_delay=0.25, sleep=sleep), "success", ) self.assertEqual([call.args[0] for call in sleep.call_args_list], [0.25, 0.5]) def test_reraises_after_attempts_are_exhausted(self) -> None: sleep = Mock() def always_fails() -> None: raise ValueError("failure") with self.assertRaisesRegex(ValueError, "failure"): retry(always_fails, attempts=2, initial_delay=1.0, sleep=sleep) sleep.assert_called_once_with(1.0) if __name__ == "__main__": unittest.main()