0bc734c020
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>
95 lines
2.9 KiB
Python
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",)
|