From 0f6286c15466808b137024d850ffa9006c3a4df6 Mon Sep 17 00:00:00 2001 From: jerem Date: Mon, 15 Jun 2026 19:50:52 +0200 Subject: [PATCH] =?UTF-8?q?claude=5Fusage:=20backoff=20apr=C3=A8s=20=C3=A9?= =?UTF-8?q?chec=20de=20refresh=20OAuth=20(anti-429)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/integrations/claude_usage.py | 39 +++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/backend/integrations/claude_usage.py b/backend/integrations/claude_usage.py index 8d20f1e..3943d7f 100644 --- a/backend/integrations/claude_usage.py +++ b/backend/integrations/claude_usage.py @@ -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}")