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:
@@ -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
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user