{"record":{"id":"178bdfdcf39393b4","repo":"NousResearch/hermes-agent","slug":"anthropic-token-refresh-failed","errorCode":null,"errorMessage":"Anthropic token refresh failed","messagePattern":"Anthropic token refresh failed","errorType":"exception","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"agent/anthropic_adapter.py","lineNumber":1186,"sourceCode":"        except Exception as exc:\n            last_error = exc\n            logger.debug(\"Anthropic token refresh failed at %s: %s\", endpoint, exc)\n            continue\n\n        access_token = result.get(\"access_token\", \"\")\n        if not access_token:\n            raise ValueError(\"Anthropic refresh response was missing access_token\")\n        next_refresh = result.get(\"refresh_token\", refresh_token)\n        expires_in = result.get(\"expires_in\", 3600)\n        return {\n            \"access_token\": access_token,\n            \"refresh_token\": next_refresh,\n            \"expires_at_ms\": int(time.time() * 1000) + (expires_in * 1000),\n        }\n\n    if last_error is not None:\n        raise last_error\n    raise ValueError(\"Anthropic token refresh failed\")\n\n\ndef _refresh_oauth_token(creds: Dict[str, Any]) -> Optional[str]:\n    \"\"\"Attempt to refresh an expired Claude Code OAuth token.\n\n    Claude Code's OAuth refresh tokens are single-use: a successful refresh\n    rotates the pair and invalidates the old refresh token. Claude Code itself\n    also refreshes on its own schedule (IDE/CLI activity), so by the time\n    Hermes notices an expired token, Claude Code may have already rotated it.\n    POSTing our now-stale refresh token in that window races Claude Code and\n    fails with ``invalid_grant``.\n\n    So before refreshing, re-read the live credential sources. If Claude Code\n    has already produced a valid token, adopt it and skip the POST entirely.\n    Only fall back to refreshing ourselves when no fresh credential is found.\n    \"\"\"\n    # Claude Code may have already refreshed — adopt its token rather than\n    # racing it with our (possibly already-rotated) refresh token. Only adopt","sourceCodeStart":1168,"sourceCodeEnd":1204,"githubUrl":"https://github.com/NousResearch/hermes-agent/blob/c896c09c42910c584c4c7d2325b58c14713ea42c/agent/anthropic_adapter.py#L1168-L1204","documentation":"Terminal failure of the Anthropic OAuth refresh loop: every candidate endpoint raised (last_error is re-raised) or the loop exhausted without success. A root cause documented right below in _refresh_oauth_token: Claude Code refresh tokens are single-use and rotated by Claude Code on its own schedule, so a stale refresh token raced to the server fails with invalid_grant.","triggerScenarios":"All refresh endpoints error — network failures, HTTP 4xx (classic invalid_grant from an already-rotated single-use refresh token), or clock skew invalidating the grant — leaving nothing to return.","commonSituations":"Hermes and the Claude Code CLI sharing the same OAuth credentials and rotating the pair concurrently; machine slept past token expiry; offline or lossy network to the token endpoints; system clock drift.","solutions":["Re-authenticate interactively (run the claude setup-token login) to mint a fresh token pair","If Claude Code also uses these credentials, avoid two owners racing the single-use refresh token","Check network/proxy reachability of the token endpoints","Verify system time via NTP — skew breaks token validity"],"exampleFix":null,"handlingStrategy":"retry","validationCode":"def refresh_token_usable(creds: dict, skew_seconds: int = 60) -> bool:\n    import time\n    exp = creds.get(\"expires_at_ms\")\n    if exp is None:\n        return False\n    return time.time() * 1000 < exp - skew_seconds * 1000\n\n# only attempt refresh when the access token is actually near expiry\nif not refresh_token_usable(creds):\n    creds = interactive_relogin()  # avoid racing the single-use refresh token","typeGuard":null,"tryCatchPattern":"for attempt in range(2):\n    try:\n        token = _refresh_oauth_token(creds)\n        break\n    except (ValueError, OSError) as e:\n        if attempt == 1 or \"invalid_grant\" not in str(getattr(e, \"__cause__\", e)):\n            token = interactive_relogin()  # single-use token already rotated — re-login\n            break","preventionTips":["Let a single owner (Hermes OR Claude Code CLI) manage the OAuth refresh; never both","Re-authenticate proactively after long sleep/shutdown periods instead of refreshing stale tokens","Keep system clocks NTP-synced to avoid spurious invalid_grant"],"tags":["oauth","anthropic","token-refresh","auth"],"backgroundTag":null,"analyzedSha":"c896c09c42910c584c4c7d2325b58c14713ea42c","analyzedAt":"2026-08-14T17:18:01.089Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}