from __future__ import annotations import asyncio import random import time from dataclasses import dataclass @dataclass class BackoffConfig: base_seconds: float = 5.0 max_seconds: float = 300.0 jitter_pct: float = 0.2 multiplier: float = 2.0 class ExponentialBackoff: def __init__(self, config: BackoffConfig | None = None): self._config = config or BackoffConfig() self._attempt = 0 self._last_attempt_time: float = 0 @property def attempt(self) -> int: return self._attempt @property def next_delay(self) -> float: if self._attempt == 0: return 0 base = min( self._config.base_seconds * (self._config.multiplier ** (self._attempt - 1)), self._config.max_seconds, ) jitter = base * self._config.jitter_pct * (random.random() * 2 - 1) return base + jitter def reset(self) -> None: self._attempt = 0 self._last_attempt_time = 0 def mark_attempt(self) -> None: self._attempt += 1 self._last_attempt_time = time.monotonic() async def wait(self) -> None: delay = self.next_delay if delay > 0: await asyncio.sleep(delay) async def execute(self, func, *args, **kwargs): while True: try: self.mark_attempt() return await func(*args, **kwargs) except Exception as e: if not _is_retryable(e): raise if self.next_delay >= self._config.max_seconds: raise await self.wait() def _is_retryable(exception: Exception) -> bool: return getattr(exception, "retryable", False)