Files
cleveragents-core/tools/_backoff.py
T
drew 0bc734c020 style: ruff format the controller-state-machine branch (288 files)
Applies `ruff format` to the accumulated formatting debt on this branch.
Formatting-only — no behavioral changes. Required for CI/lint's format
gate (`nox -s format -- --check`), which the branch was failing on 288
tracked files that drifted from ruff's canonical style.

In-progress WIP files are intentionally excluded so this commit stays a
clean formatting-only diff.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 00:09:17 -04:00

95 lines
2.9 KiB
Python

"""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",)