from collections.abc import Callable from time import sleep from typing import ParamSpec, TypeVar P = ParamSpec("P") R = TypeVar("R") def retry( operation: Callable[P, R], *args: P.args, attempts: int = 3, initial_delay: float = 0.5, backoff_factor: float = 2.0, retry_on: tuple[type[Exception], ...] = (Exception,), sleep_fn: Callable[[float], None] = sleep, **kwargs: P.kwargs, ) -> R: """Run an operation, 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 < 1: raise ValueError("backoff_factor must be at least 1") delay = initial_delay for attempt in range(attempts): try: return operation(*args, **kwargs) except retry_on: if attempt == attempts - 1: raise sleep_fn(delay) delay *= backoff_factor raise RuntimeError("unreachable") def _run_tests() -> None: calls = 0 delays: list[float] = [] def flaky(value: int) -> int: nonlocal calls calls += 1 if calls < 3: raise OSError("temporary failure") return value * 2 assert retry( flaky, 5, attempts=4, initial_delay=0.25, sleep_fn=delays.append, ) == 10 assert calls == 3 assert delays == [0.25, 0.5] try: retry( lambda: (_ for _ in ()).throw(ValueError("permanent failure")), attempts=2, initial_delay=0, sleep_fn=lambda _: None, ) except ValueError as error: assert str(error) == "permanent failure" else: raise AssertionError("retry should re-raise the final exception") if __name__ == "__main__": _run_tests()