Claude: persiste le backoff OAuth sur disque (un redéploiement ne re-sollicite plus le 429)
Le backoff anti-429 du refresh OAuth vivait uniquement en mémoire : chaque redéploiement le remettait à zéro et re-sollicitait IMMÉDIATEMENT l'endpoint de refresh rate-limité, entretenant le 429 qu'on cherche justement à laisser retomber. Persiste backoff_until + le palier exponentiel (failures) sur /data (claude_oauth_state.json), écriture atomique best-effort à la manière du cache trackers. Chargé une fois par process en tête de fetch_usage, sauvé à chaque échec et effacé à chaque succès. Un token frais court-circuite de toute façon le backoff, donc un re-login isolé débloque immédiatement même si une fenêtre court. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -73,6 +73,13 @@ class Config:
|
|||||||
claude_refresh_timeout: int = field(
|
claude_refresh_timeout: int = field(
|
||||||
default_factory=lambda: int(_get("MONITORINK_CLAUDE_REFRESH_TIMEOUT", "45"))
|
default_factory=lambda: int(_get("MONITORINK_CLAUDE_REFRESH_TIMEOUT", "45"))
|
||||||
)
|
)
|
||||||
|
# État du backoff OAuth (anti-429) persisté sur disque. Sans ça, le backoff vit uniquement en
|
||||||
|
# mémoire : chaque redéploiement le remet à zéro et re-sollicite IMMÉDIATEMENT l'endpoint de
|
||||||
|
# refresh rate-limité, entretenant le 429 qu'on essaie justement de laisser retomber. Le
|
||||||
|
# persister fait survivre la fenêtre (et le palier exponentiel) aux rebuilds. Volume /data.
|
||||||
|
claude_state_file: str = field(
|
||||||
|
default_factory=lambda: _get("MONITORINK_CLAUDE_STATE_FILE", "/data/claude_oauth_state.json")
|
||||||
|
)
|
||||||
|
|
||||||
# --- Météo (Open-Meteo, sans clé) ---
|
# --- Météo (Open-Meteo, sans clé) ---
|
||||||
weather_lat: float = field(default_factory=lambda: float(_get("MONITORINK_LAT", "48.8566")))
|
weather_lat: float = field(default_factory=lambda: float(_get("MONITORINK_LAT", "48.8566")))
|
||||||
|
|||||||
@@ -52,6 +52,44 @@ REFRESH_BACKOFF_BASE = 600 # 1er palier après un échec : 10 min
|
|||||||
REFRESH_BACKOFF_CAP = 6 * 3600 # plafond : 6 h
|
REFRESH_BACKOFF_CAP = 6 * 3600 # plafond : 6 h
|
||||||
_refresh_state: dict[str, float] = {"backoff_until": 0.0, "failures": 0.0}
|
_refresh_state: dict[str, float] = {"backoff_until": 0.0, "failures": 0.0}
|
||||||
|
|
||||||
|
# Le backoff ci-dessus est PERSISTÉ sur disque (/data). En mémoire seule, un redéploiement le
|
||||||
|
# remettait à zéro et re-sollicitait l'endpoint rate-limité dès le 1er rendu -> 429 entretenu en
|
||||||
|
# boucle de rebuild. Le persister fait survivre la fenêtre (et le palier exponentiel #failures)
|
||||||
|
# aux redémarrages. Un token frais court-circuite de toute façon le backoff (cf. _valid_token),
|
||||||
|
# donc un re-login isolé débloque immédiatement même si une fenêtre persistée court encore.
|
||||||
|
_state_loaded = False # l'état de backoff n'est lu du disque qu'une fois par process
|
||||||
|
|
||||||
|
|
||||||
|
def _load_state() -> None:
|
||||||
|
"""Hydrate `_refresh_state` depuis le disque (une fois par process). Fichier absent/illisible
|
||||||
|
/corrompu -> backoff vide (on retentera : comportement sûr au pire)."""
|
||||||
|
global _state_loaded
|
||||||
|
if _state_loaded:
|
||||||
|
return
|
||||||
|
_state_loaded = True
|
||||||
|
try:
|
||||||
|
with open(config.claude_state_file, encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
_refresh_state["backoff_until"] = float(data["backoff_until"])
|
||||||
|
_refresh_state["failures"] = float(data["failures"])
|
||||||
|
except (OSError, ValueError, KeyError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _save_state() -> None:
|
||||||
|
"""Persiste `_refresh_state` de façon atomique (tmp + os.replace). Best-effort : une erreur
|
||||||
|
d'écriture ne doit jamais casser le rendu (au pire on perd le backoff au prochain reboot)."""
|
||||||
|
path = config.claude_state_file
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||||
|
tmp = f"{path}.tmp"
|
||||||
|
with open(tmp, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump({"backoff_until": _refresh_state["backoff_until"],
|
||||||
|
"failures": _refresh_state["failures"]}, fh)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _retry_after_seconds(exc: Exception) -> float | None:
|
def _retry_after_seconds(exc: Exception) -> float | None:
|
||||||
"""Secondes de l'en-tête Retry-After d'une réponse HTTP d'erreur, si présent et numérique."""
|
"""Secondes de l'en-tête Retry-After d'une réponse HTTP d'erreur, si présent et numérique."""
|
||||||
@@ -76,6 +114,7 @@ def _note_refresh_failure(exc: Exception) -> None:
|
|||||||
if retry_after is not None:
|
if retry_after is not None:
|
||||||
delay = max(delay, retry_after)
|
delay = max(delay, retry_after)
|
||||||
_refresh_state["backoff_until"] = time.time() + delay
|
_refresh_state["backoff_until"] = time.time() + delay
|
||||||
|
_save_state() # la fenêtre doit survivre à un redéploiement (sinon 429 re-sollicité au reboot)
|
||||||
log.warning("refresh OAuth Claude échoué (échec #%d) — backoff %ds : %s", n, int(delay), exc)
|
log.warning("refresh OAuth Claude échoué (échec #%d) — backoff %ds : %s", n, int(delay), exc)
|
||||||
|
|
||||||
|
|
||||||
@@ -83,6 +122,7 @@ def _note_refresh_success() -> None:
|
|||||||
"""Refresh réussi : on réarme à zéro (palier + fenêtre de backoff)."""
|
"""Refresh réussi : on réarme à zéro (palier + fenêtre de backoff)."""
|
||||||
_refresh_state["failures"] = 0
|
_refresh_state["failures"] = 0
|
||||||
_refresh_state["backoff_until"] = 0.0
|
_refresh_state["backoff_until"] = 0.0
|
||||||
|
_save_state() # efface le backoff persisté : le prochain reboot repart propre
|
||||||
|
|
||||||
|
|
||||||
class _RefreshThrottled(Exception):
|
class _RefreshThrottled(Exception):
|
||||||
@@ -346,6 +386,7 @@ _usage_cache: dict[str, object] = {"value": None, "ts": 0.0}
|
|||||||
|
|
||||||
|
|
||||||
async def fetch_usage() -> ClaudeUsage:
|
async def fetch_usage() -> ClaudeUsage:
|
||||||
|
_load_state() # hydrate le backoff persisté avant toute logique de refresh (1×/process)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
cached = _usage_cache["value"]
|
cached = _usage_cache["value"]
|
||||||
if isinstance(cached, ClaudeUsage) and cached.ok and (now - float(_usage_cache["ts"])) < config.usage_ttl_seconds:
|
if isinstance(cached, ClaudeUsage) and cached.ok and (now - float(_usage_cache["ts"])) < config.usage_ttl_seconds:
|
||||||
|
|||||||
Reference in New Issue
Block a user