Claude: backoff exponentiel du refresh OAuth (corrige le 429 perpétuel)
Le token d'accès vit ~8h ; à expiration, fetch_usage retentait un refresh à chaque rendu (~15min) avec un backoff fixe de 5min toujours déjà expiré. Chaque tentative re-saturait le rate-limit /v1/oauth/token -> 429 en boucle (>15h observé), token jamais rafraîchi, usage figé sur la dernière valeur en cache. - backoff exponentiel 10min -> 6h (au lieu de 5min fixes), réinitialisé sur succès - respect de l'en-tête Retry-After quand il dépasse le palier - logging succès/échec du refresh (le chemin n'en avait aucun -> diag à l'aveugle) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -29,6 +30,8 @@ import httpx
|
||||
|
||||
from config import config
|
||||
|
||||
log = logging.getLogger("monitorink.claude")
|
||||
|
||||
USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
|
||||
REFRESH_URL = "https://platform.claude.com/v1/oauth/token"
|
||||
CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" # client public Claude Code
|
||||
@@ -39,8 +42,47 @@ _refresh_lock = asyncio.Lock()
|
||||
# Backoff après échec du refresh OAuth (typiquement 429) : sans ça, le token expiré déclenche un
|
||||
# refresh À CHAQUE rendu, l'échec n'étant pas mémorisé -> on martèle platform.claude.com et on
|
||||
# entretient le rate-limit. Pendant la fenêtre on ne retente pas (on sert la dernière valeur connue).
|
||||
REFRESH_BACKOFF_SECONDS = 300
|
||||
_refresh_state: dict[str, float] = {"backoff_until": 0.0}
|
||||
#
|
||||
# Le backoff est EXPONENTIEL (10min, 20, 40... plafonné à 6h) et non plus fixe : un backoff court
|
||||
# (5min) couplé à la cadence de rendu (~15min) faisait retenter le refresh ~4×/h en boucle, ce qui
|
||||
# resaturait en permanence le rate-limit 429 et l'empêchait de se résorber (token figé >15h observé).
|
||||
# Comme le token vit ~8h, espacer fortement les retentatives ne coûte rien et laisse le 429 retomber.
|
||||
# On respecte aussi l'en-tête Retry-After de l'API quand il est plus long que notre palier.
|
||||
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}
|
||||
|
||||
|
||||
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."""
|
||||
resp = getattr(exc, "response", None)
|
||||
if resp is None:
|
||||
return None
|
||||
raw = resp.headers.get("retry-after")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return float(raw)
|
||||
except ValueError:
|
||||
return None # forme date-HTTP : on ignore, le palier exponentiel suffit
|
||||
|
||||
|
||||
def _note_refresh_failure(exc: Exception) -> None:
|
||||
"""Enregistre un échec de refresh et arme le backoff exponentiel (borné, ≥ Retry-After)."""
|
||||
n = int(_refresh_state["failures"]) + 1
|
||||
_refresh_state["failures"] = n
|
||||
delay = min(REFRESH_BACKOFF_BASE * (2 ** (n - 1)), REFRESH_BACKOFF_CAP)
|
||||
retry_after = _retry_after_seconds(exc)
|
||||
if retry_after is not None:
|
||||
delay = max(delay, retry_after)
|
||||
_refresh_state["backoff_until"] = time.time() + delay
|
||||
log.warning("refresh OAuth Claude échoué (échec #%d) — backoff %ds : %s", n, int(delay), exc)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class _RefreshThrottled(Exception):
|
||||
@@ -163,6 +205,9 @@ async def _refresh(data: dict) -> str:
|
||||
o["refreshToken"] = tok["refresh_token"]
|
||||
o["expiresAt"] = int(time.time() * 1000) + int(tok["expires_in"]) * 1000
|
||||
_write_creds(data)
|
||||
_note_refresh_success()
|
||||
log.info("refresh OAuth Claude réussi — token valide jusqu'à %s",
|
||||
datetime.fromtimestamp(o["expiresAt"] / 1000, timezone.utc).isoformat())
|
||||
return o["accessToken"]
|
||||
|
||||
|
||||
@@ -187,8 +232,8 @@ async def _valid_token() -> str:
|
||||
raise _RefreshThrottled()
|
||||
try:
|
||||
return await _refresh(data)
|
||||
except httpx.HTTPError:
|
||||
_refresh_state["backoff_until"] = time.time() + REFRESH_BACKOFF_SECONDS
|
||||
except httpx.HTTPError as exc:
|
||||
_note_refresh_failure(exc)
|
||||
raise
|
||||
|
||||
|
||||
@@ -268,8 +313,8 @@ async def _fetch_usage() -> ClaudeUsage:
|
||||
data = _load_creds()
|
||||
async with _refresh_lock:
|
||||
token = await _refresh(data)
|
||||
except httpx.HTTPError:
|
||||
_refresh_state["backoff_until"] = time.time() + REFRESH_BACKOFF_SECONDS
|
||||
except httpx.HTTPError as exc:
|
||||
_note_refresh_failure(exc)
|
||||
raise
|
||||
resp = await _get_usage(token)
|
||||
except httpx.HTTPError as exc:
|
||||
|
||||
Reference in New Issue
Block a user