claude_usage: backoff après échec de refresh OAuth (anti-429)
Token expiré -> refresh tenté à chaque rendu, échec non mémorisé -> on martelait platform.claude.com et le 429 s'entretenait. On impose désormais un backoff de 5 min après un refresh échoué (et sur la voie de refresh forcé 401), pendant lequel on sert la dernière valeur connue au lieu de re-tenter.
This commit is contained in:
@@ -36,6 +36,16 @@ EXPIRY_BUFFER_MS = 120_000 # rafraîchit 2 min avant expiration
|
||||
|
||||
_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}
|
||||
|
||||
|
||||
class _RefreshThrottled(Exception):
|
||||
"""Refresh en backoff (anti-429), pas une vraie erreur réseau/auth."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Window:
|
||||
@@ -157,18 +167,29 @@ async def _refresh(data: dict) -> str:
|
||||
|
||||
|
||||
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 si nécessaire (sérialisé).
|
||||
|
||||
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."""
|
||||
data = _load_creds()
|
||||
o = data["claudeAiOauth"]
|
||||
if int(o.get("expiresAt", 0)) - int(time.time() * 1000) > EXPIRY_BUFFER_MS:
|
||||
return o["accessToken"]
|
||||
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.
|
||||
data = _load_creds()
|
||||
o = data["claudeAiOauth"]
|
||||
if int(o.get("expiresAt", 0)) - int(time.time() * 1000) > EXPIRY_BUFFER_MS:
|
||||
return o["accessToken"]
|
||||
if time.time() < _refresh_state["backoff_until"]:
|
||||
raise _RefreshThrottled()
|
||||
try:
|
||||
return await _refresh(data)
|
||||
except httpx.HTTPError:
|
||||
_refresh_state["backoff_until"] = time.time() + REFRESH_BACKOFF_SECONDS
|
||||
raise
|
||||
|
||||
|
||||
async def _get_usage(token: str) -> httpx.Response:
|
||||
@@ -232,6 +253,8 @@ async def _fetch_usage() -> ClaudeUsage:
|
||||
|
||||
try:
|
||||
token = await _valid_token()
|
||||
except _RefreshThrottled:
|
||||
return ClaudeUsage(ok=False, error="refresh en pause (anti-429)")
|
||||
except (OSError, KeyError, json.JSONDecodeError) as exc:
|
||||
return ClaudeUsage(ok=False, error=f"credentials illisibles: {exc}")
|
||||
except httpx.HTTPError as exc:
|
||||
@@ -239,11 +262,15 @@ async def _fetch_usage() -> ClaudeUsage:
|
||||
|
||||
try:
|
||||
resp = await _get_usage(token)
|
||||
# Token rejeté malgré le buffer -> une tentative de refresh forcé.
|
||||
# Token rejeté malgré le buffer -> 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 httpx.HTTPError:
|
||||
_refresh_state["backoff_until"] = time.time() + REFRESH_BACKOFF_SECONDS
|
||||
raise
|
||||
resp = await _get_usage(token)
|
||||
except httpx.HTTPError as exc:
|
||||
return ClaudeUsage(ok=False, error=f"réseau: {exc}")
|
||||
|
||||
Reference in New Issue
Block a user