From 2c5acc1e36dcfb44a3f2aeb6f3a05de18b05ae82 Mon Sep 17 00:00:00 2001 From: Jerem Date: Thu, 18 Jun 2026 09:58:02 +0200 Subject: [PATCH] =?UTF-8?q?Claude:=20backoff=20exponentiel=20du=20refresh?= =?UTF-8?q?=20OAuth=20(corrige=20le=20429=20perp=C3=A9tuel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/integrations/claude_usage.py | 57 +++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/backend/integrations/claude_usage.py b/backend/integrations/claude_usage.py index 3943d7f..76457a6 100644 --- a/backend/integrations/claude_usage.py +++ b/backend/integrations/claude_usage.py @@ -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: