diff --git a/.env.example b/.env.example index 8db5902..e8b708f 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,12 @@ MONITORINK_CLAUDE_CREDS=/creds/.credentials.json MONITORINK_CLAUDE_UA=claude-code/2.1.172 # Burn rate via ccusage (nécessite ccusage installé + ~/.claude/projects monté). 0/1 MONITORINK_CCUSAGE=0 +# Marge proactive de refresh OAuth (minutes) : rafraîchit le token bien avant son expiration +# (~8 h) pour laisser des heures de marge en cas d'échec transitoire (429/réseau). Défaut 120. +MONITORINK_CLAUDE_REFRESH_LEAD_MIN=120 +# Timeout du POST de refresh (secondes). Généreux pour ne pas couper après que le serveur a +# rotaté le refresh token, ce qui le perdrait et forcerait un re-login manuel. Défaut 45. +MONITORINK_CLAUDE_REFRESH_TIMEOUT=45 # Affichage — canevas de rendu en PAYSAGE ; le PNG est pivoté de 90° pour la Kobo. MONITORINK_TZ=Europe/Paris diff --git a/backend/config.py b/backend/config.py index 95a9c82..736fec2 100644 --- a/backend/config.py +++ b/backend/config.py @@ -78,6 +78,18 @@ class Config: ccusage_enabled: bool = field( default_factory=lambda: _get("MONITORINK_CCUSAGE", "0") in ("1", "true", "yes") ) + # Marge proactive de refresh OAuth : on rafraîchit dès qu'il reste MOINS que ça sur le token + # (~8 h de vie), au lieu d'attendre les toutes dernières minutes. 2 h par défaut -> refresh + # ~6 h avant l'échéance, ce qui laisse des heures de marge pour retenter un échec transitoire + # (429/réseau) AVANT que l'access token meure, et fait tourner le refresh token rotatif tôt. + claude_refresh_lead_minutes: int = field( + default_factory=lambda: int(_get("MONITORINK_CLAUDE_REFRESH_LEAD_MIN", "120")) + ) + # Timeout du POST de refresh. Généreux : réduit la fenêtre où le serveur a rotaté le refresh + # token sans qu'on ait pu le persister (cause n°1 des reconnexions manuelles). Secondes. + claude_refresh_timeout: int = field( + default_factory=lambda: int(_get("MONITORINK_CLAUDE_REFRESH_TIMEOUT", "45")) + ) # --- Météo (Open-Meteo, sans clé) --- weather_lat: float = field(default_factory=lambda: float(_get("MONITORINK_LAT", "48.8566"))) @@ -127,6 +139,11 @@ class Config: default_factory=lambda: int(_get("MONITORINK_FULL_INTERVAL_MIN", "120")) ) + @property + def claude_refresh_lead_ms(self) -> int: + """Marge proactive de refresh en millisecondes (cf. claude_refresh_lead_minutes).""" + return self.claude_refresh_lead_minutes * 60_000 + @property def ha_entities(self) -> list[HAEntity]: return [HAEntity.parse(s) for s in _get_list("MONITORINK_HA_ENTITIES")] diff --git a/backend/integrations/claude_usage.py b/backend/integrations/claude_usage.py index 76457a6..f76a592 100644 --- a/backend/integrations/claude_usage.py +++ b/backend/integrations/claude_usage.py @@ -89,6 +89,28 @@ class _RefreshThrottled(Exception): """Refresh en backoff (anti-429), pas une vraie erreur réseau/auth.""" +class _RefreshFatal(Exception): + """Refresh refusé DÉFINITIVEMENT (invalid_grant / invalid_request / 401) : le refresh token + est mort, seul un nouveau login isolé répare. À distinguer absolument d'un 429/réseau (transitoire) : + inutile de backoff/retenter, et surtout il ne faut PAS re-soumettre ce token en boucle.""" + + +# Un login isolé est requis (refresh révoqué). Armé sur erreur fatale, désarmé dès qu'un refresh +# réussit OU qu'on relit un token redevenu frais (= l'utilisateur a relancé le login CLI). Sert à +# court-circuiter les refresh (on sait le token mort) et à afficher une alerte claire à l'écran. +_login_required: dict[str, object] = {"flag": False, "detail": ""} + + +def _set_login_required(detail: str) -> None: + _login_required["flag"] = True + _login_required["detail"] = detail + + +def _clear_login_required() -> None: + _login_required["flag"] = False + _login_required["detail"] = "" + + @dataclass class Window: """Une fenêtre glissante d'usage (5h ou 7j).""" @@ -144,6 +166,9 @@ class ExtraUsage: class ClaudeUsage: ok: bool error: str | None = None + # True quand l'erreur exige une action humaine (relancer le login isolé). Empêche le repli + # silencieux sur le cache : on veut que le message remonte à l'écran. + fatal: bool = False five_hour: Window | None = None seven_day: Window | None = None seven_day_opus: Window | None = None @@ -186,53 +211,99 @@ def _write_creds(data: dict) -> None: raise +# Codes d'erreur OAuth qui signifient « ce refresh token est mort » (≠ rate-limit/réseau). +_FATAL_REFRESH_ERRORS = {"invalid_grant", "invalid_request", "invalid_client"} + + async def _refresh(data: dict) -> str: - """Rafraîchit l'access token, réécrit le fichier isolé, renvoie le nouvel access token.""" + """Rafraîchit l'access token, réécrit le fichier isolé, renvoie le nouvel access token. + + Le refresh token est ROTATIF : un refresh réussi le remplace et invalide l'ancien. On NE + retente JAMAIS en interne avec le même token (pas de boucle for / tenacity ici) : re-soumettre + un token déjà consommé peut déclencher la reuse-detection côté Anthropic et révoquer toute la + famille -> login forcé. L'espacement des tentatives vient du backoff de l'appelant.""" o = data["claudeAiOauth"] body = { "grant_type": "refresh_token", "refresh_token": o["refreshToken"], "client_id": CLIENT_ID, } - async with httpx.AsyncClient(timeout=20) as client: + async with httpx.AsyncClient(timeout=config.claude_refresh_timeout) as client: resp = await client.post( REFRESH_URL, json=body, headers={"Content-Type": "application/json", "User-Agent": config.claude_ua}, ) - resp.raise_for_status() + # 400 avec un code d'erreur OAuth fatal (ou 401) -> le refresh token est mort : seul un nouveau + # login isolé répare. On arme l'alerte et on lève _RefreshFatal SANS backoff (le 429, lui, est + # transitoire et part dans raise_for_status ci-dessous). + if resp.status_code in (400, 401): + err = "" + try: + err = str((resp.json() or {}).get("error", "")) + except ValueError: + pass + if err in _FATAL_REFRESH_ERRORS or resp.status_code == 401: + _set_login_required(err or f"HTTP {resp.status_code}") + log.error("LOGIN CLAUDE REQUIS — relancer le login isolé " + "(CLAUDE_CONFIG_DIR=… claude auth login) ; refresh rejeté: %s", + err or resp.status_code) + raise _RefreshFatal(err or f"HTTP {resp.status_code}") + resp.raise_for_status() # 429/5xx/autre -> httpx.HTTPError, traité en transitoire (backoff) tok = resp.json() o["accessToken"] = tok["access_token"] o["refreshToken"] = tok["refresh_token"] o["expiresAt"] = int(time.time() * 1000) + int(tok["expires_in"]) * 1000 - _write_creds(data) + try: + _write_creds(data) + except OSError: + # Token frais en mémoire mais non persisté : au prochain démarrage le disque garde l'ANCIEN + # token (déjà consommé) -> rejoue la cause n°1 des reconnexions. On le signale fort. + log.error("nouveau refresh token NON persisté (échec écriture %s)", config.claude_creds_path) + raise _note_refresh_success() + _clear_login_required() # un refresh réussi prouve que l'auth est de nouveau bonne log.info("refresh OAuth Claude réussi — token valide jusqu'à %s", datetime.fromtimestamp(o["expiresAt"] / 1000, timezone.utc).isoformat()) return o["accessToken"] async def _valid_token() -> str: - """Renvoie un access token valide, en rafraîchissant si nécessaire (sérialisé). + """Renvoie un access token valide, en rafraîchissant PROACTIVEMENT bien avant l'expiration. - Après un échec de refresh (ex. 429), on impose un backoff et on lève _RefreshThrottled - pendant la pause : l'appelant sert alors la dernière valeur connue au lieu de re-marteler.""" + - Refresh dès qu'il reste moins de `claude_refresh_lead_ms` (≈2 h) sur le token (~8 h de vie) : + un échec transitoire (429) a alors des heures de marge pour réussir avant que l'access token + meure, et le refresh token rotatif tourne tôt (loin du chemin critique). + - Échec transitoire -> backoff + _RefreshThrottled : l'appelant sert la dernière valeur connue. + - Refresh token mort (invalid_grant/401) -> _RefreshFatal, sans backoff ni re-soumission en + boucle. L'alerte ne retombe qu'au prochain login isolé (token redevenu frais sur disque).""" + # Plancher défensif : même mal configuré (LEAD_MIN=0), on rafraîchit au moins juste avant l'expiry. + lead = max(config.claude_refresh_lead_ms, EXPIRY_BUFFER_MS) data = _load_creds() o = data["claudeAiOauth"] - if int(o.get("expiresAt", 0)) - int(time.time() * 1000) > EXPIRY_BUFFER_MS: + if int(o.get("expiresAt", 0)) - int(time.time() * 1000) > lead: + _clear_login_required() # token frais : si une alerte était armée, un re-login l'a réparée return o["accessToken"] + if _login_required["flag"]: + raise _RefreshFatal(str(_login_required["detail"])) # token mort connu : pas d'appel réseau if time.time() < _refresh_state["backoff_until"]: raise _RefreshThrottled() async with _refresh_lock: - # Re-lecture après acquisition du lock : un autre coroutine a peut-être déjà rafraîchi. + # Re-lecture après acquisition du lock : un autre coroutine a peut-être déjà rafraîchi, ou + # l'utilisateur a relancé le login isolé entre-temps (token redevenu frais -> on désarme). data = _load_creds() o = data["claudeAiOauth"] - if int(o.get("expiresAt", 0)) - int(time.time() * 1000) > EXPIRY_BUFFER_MS: + if int(o.get("expiresAt", 0)) - int(time.time() * 1000) > lead: + _clear_login_required() return o["accessToken"] + if _login_required["flag"]: + raise _RefreshFatal(str(_login_required["detail"])) if time.time() < _refresh_state["backoff_until"]: raise _RefreshThrottled() try: return await _refresh(data) except httpx.HTTPError as exc: + # Transitoire (429/5xx/réseau/timeout). _RefreshFatal, lui, n'est PAS un httpx.HTTPError : + # il remonte tel quel, sans backoff. _note_refresh_failure(exc) raise @@ -285,9 +356,10 @@ async def fetch_usage() -> ClaudeUsage: _usage_cache["value"] = result _usage_cache["ts"] = now return result - # Erreur (429, réseau, auth transitoire) : on réaffiche la dernière valeur correcte connue - # plutôt qu'un message d'erreur, le temps que ça se rétablisse. - if isinstance(cached, ClaudeUsage) and cached.ok: + # Erreur TRANSITOIRE (429, réseau, auth temporaire) : on réaffiche la dernière valeur correcte + # connue plutôt qu'un message d'erreur, le temps que ça se rétablisse. En revanche une erreur + # FATALE (login isolé à relancer) doit passer en clair à l'écran : pas de repli cache. + if not result.fatal and isinstance(cached, ClaudeUsage) and cached.ok: return cached return result @@ -300,6 +372,8 @@ async def _fetch_usage() -> ClaudeUsage: token = await _valid_token() except _RefreshThrottled: return ClaudeUsage(ok=False, error="refresh en pause (anti-429)") + except _RefreshFatal: + return ClaudeUsage(ok=False, fatal=True, error="Reconnexion Claude requise") except (OSError, KeyError, json.JSONDecodeError) as exc: return ClaudeUsage(ok=False, error=f"credentials illisibles: {exc}") except httpx.HTTPError as exc: @@ -307,12 +381,14 @@ async def _fetch_usage() -> ClaudeUsage: try: resp = await _get_usage(token) - # Token rejeté malgré le buffer -> une tentative de refresh forcé (soumise au backoff). + # Token rejeté malgré la marge -> une tentative de refresh forcé (soumise au backoff). if resp.status_code == 401: try: data = _load_creds() async with _refresh_lock: token = await _refresh(data) + except _RefreshFatal: + return ClaudeUsage(ok=False, fatal=True, error="Reconnexion Claude requise") except httpx.HTTPError as exc: _note_refresh_failure(exc) raise