"""Shared exponential-backoff state machine for the cache modules. Three caches (:mod:`_pr_comments_cache`, :mod:`_ci_logs`, :mod:`_pr_classification_cache`) previously each carried a copy of the same ``next_attempt_after = now + min(BASE * 2**(failures-1), MAX)`` machinery. Centralised here so a future tweak (jitter, cap, etc.) lands once. Usage:: BACKOFF = Backoff( base_env="CI_LOGS_BACKOFF_BASE_S", max_env="CI_LOGS_BACKOFF_MAX_S", base_default=60, max_default=1800, ) next_iso = BACKOFF.next_attempt_after(failures=3, now_dt=now) if BACKOFF.is_active(cached, now_dt=now): return cached, False """ from __future__ import annotations import datetime as _dt import os from dataclasses import dataclass from typing import Any @dataclass(frozen=True) class Backoff: """Bundle the four env-tunable knobs that govern one cache's backoff. Frozen because instances are module-level constants; each cache module instantiates one at import time.""" base_env: str max_env: str base_default: int = 60 max_default: int = 1800 def base_s(self) -> int: return _read_positive_int_env(self.base_env, self.base_default) def max_s(self) -> int: return _read_positive_int_env(self.max_env, self.max_default) def next_attempt_after( self, failures: int, now_dt: _dt.datetime, ) -> str | None: """Return an ISO-8601 timestamp ``failures`` retries out, or ``None`` if ``failures <= 0`` (no backoff window required). The exponent is clamped at 16 (2**16 ~ 18 hours of base*65536) to avoid integer overflow on adversarial inputs; the final delay is then capped at ``max_s``.""" if failures <= 0: return None raw_delay_s = self.base_s() * (2 ** min(failures - 1, 16)) delay_s = min(raw_delay_s, self.max_s()) return (now_dt + _dt.timedelta(seconds=delay_s)).isoformat() def is_active( self, cached: dict[str, Any] | None, now_dt: _dt.datetime, ) -> bool: """True iff ``cached["next_attempt_after"]`` parses as an ISO datetime in the future. Returns ``False`` for missing / malformed / past values so a corrupt cache row falls through to a fresh live fetch rather than getting stuck in stale backoff.""" if not cached: return False raw = cached.get("next_attempt_after") if not raw: return False try: deadline = _dt.datetime.fromisoformat(str(raw)) except (ValueError, TypeError): return False return now_dt < deadline def _read_positive_int_env(name: str, default: int) -> int: raw = os.environ.get(name) if raw: try: return max(1, int(raw)) except ValueError: pass return default __all__ = ("Backoff",)