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:
jerem
2026-06-15 19:50:52 +02:00
parent 5925b0f9d2
commit 0f6286c154

View File

@@ -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"]
return await _refresh(data)
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:
data = _load_creds()
async with _refresh_lock:
token = await _refresh(data)
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}")