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:
Jerem
2026-06-18 09:58:02 +02:00
parent 767e514dad
commit 2c5acc1e36

View File

@@ -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: