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:
@@ -18,6 +18,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -29,6 +30,8 @@ import httpx
|
|||||||
|
|
||||||
from config import config
|
from config import config
|
||||||
|
|
||||||
|
log = logging.getLogger("monitorink.claude")
|
||||||
|
|
||||||
USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
|
USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
|
||||||
REFRESH_URL = "https://platform.claude.com/v1/oauth/token"
|
REFRESH_URL = "https://platform.claude.com/v1/oauth/token"
|
||||||
CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" # client public Claude Code
|
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
|
# 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
|
# 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).
|
# 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):
|
class _RefreshThrottled(Exception):
|
||||||
@@ -163,6 +205,9 @@ async def _refresh(data: dict) -> str:
|
|||||||
o["refreshToken"] = tok["refresh_token"]
|
o["refreshToken"] = tok["refresh_token"]
|
||||||
o["expiresAt"] = int(time.time() * 1000) + int(tok["expires_in"]) * 1000
|
o["expiresAt"] = int(time.time() * 1000) + int(tok["expires_in"]) * 1000
|
||||||
_write_creds(data)
|
_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"]
|
return o["accessToken"]
|
||||||
|
|
||||||
|
|
||||||
@@ -187,8 +232,8 @@ async def _valid_token() -> str:
|
|||||||
raise _RefreshThrottled()
|
raise _RefreshThrottled()
|
||||||
try:
|
try:
|
||||||
return await _refresh(data)
|
return await _refresh(data)
|
||||||
except httpx.HTTPError:
|
except httpx.HTTPError as exc:
|
||||||
_refresh_state["backoff_until"] = time.time() + REFRESH_BACKOFF_SECONDS
|
_note_refresh_failure(exc)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
@@ -268,8 +313,8 @@ async def _fetch_usage() -> ClaudeUsage:
|
|||||||
data = _load_creds()
|
data = _load_creds()
|
||||||
async with _refresh_lock:
|
async with _refresh_lock:
|
||||||
token = await _refresh(data)
|
token = await _refresh(data)
|
||||||
except httpx.HTTPError:
|
except httpx.HTTPError as exc:
|
||||||
_refresh_state["backoff_until"] = time.time() + REFRESH_BACKOFF_SECONDS
|
_note_refresh_failure(exc)
|
||||||
raise
|
raise
|
||||||
resp = await _get_usage(token)
|
resp = await _get_usage(token)
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
|
|||||||
Reference in New Issue
Block a user