diff --git a/backend/config.py b/backend/config.py index 0d1dfa6..00cd856 100644 --- a/backend/config.py +++ b/backend/config.py @@ -73,6 +73,13 @@ class Config: claude_refresh_timeout: int = field( 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é) --- weather_lat: float = field(default_factory=lambda: float(_get("MONITORINK_LAT", "48.8566"))) diff --git a/backend/integrations/claude_usage.py b/backend/integrations/claude_usage.py index f76a592..e67ae92 100644 --- a/backend/integrations/claude_usage.py +++ b/backend/integrations/claude_usage.py @@ -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_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: """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: delay = max(delay, retry_after) _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) @@ -83,6 +122,7 @@ def _note_refresh_success() -> None: """Refresh réussi : on réarme à zéro (palier + fenêtre de backoff).""" _refresh_state["failures"] = 0 _refresh_state["backoff_until"] = 0.0 + _save_state() # efface le backoff persisté : le prochain reboot repart propre class _RefreshThrottled(Exception): @@ -346,6 +386,7 @@ _usage_cache: dict[str, object] = {"value": None, "ts": 0.0} async def fetch_usage() -> ClaudeUsage: + _load_state() # hydrate le backoff persisté avant toute logique de refresh (1×/process) now = time.time() cached = _usage_cache["value"] if isinstance(cached, ClaudeUsage) and cached.ok and (now - float(_usage_cache["ts"])) < config.usage_ttl_seconds: