Claude: refresh OAuth proactif + erreurs fatales distinctes (fini les reconnexions manuelles)

Le refresh token est rotatif : la chaîne se régénère seule indéfiniment tant que
le nouveau token est persisté. La reconnexion manuelle n'était requise que lorsque
cet invariant cassait. Trois correctifs :

- Refresh PROACTIF : on rafraîchit dès qu'il reste < 2h sur le token (~8h de vie)
  au lieu des 2 dernières minutes. Un échec transitoire a des heures de marge avant
  que l'access token meure ; la fenêtre où un kill/timeout perd le token rotatif
  fraîchement rotaté passe de ~8h à quelques ms. Réglable via
  MONITORINK_CLAUDE_REFRESH_LEAD_MIN (défaut 120).
- Distinction FATAL vs TRANSITOIRE : 400 invalid_grant / 401 sur l'endpoint token
  -> _RefreshFatal, sans backoff ni re-soumission en boucle (évite la reuse-detection
  qui révoque toute la famille). 429/5xx/réseau gardent le backoff exponentiel.
- Visibilité + auto-réparation : le cas fatal affiche "Reconnexion Claude requise"
  (pas de repli cache silencieux) et l'alerte se referme seule dès qu'un token frais
  réapparaît sur disque (re-login isolé), sans redémarrer le conteneur.

Timeout du POST de refresh porté à 45s (réglable, MONITORINK_CLAUDE_REFRESH_TIMEOUT)
pour réduire la fenêtre de perte du token après rotation serveur.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 10:46:21 +02:00
parent 2c5acc1e36
commit 53fefd4654
3 changed files with 113 additions and 14 deletions

View File

@@ -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

View File

@@ -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")]

View File

@@ -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
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