fix(anthropic): serialize Claude Code OAuth refresh under the auth-store lock - #60639
Open
loop2zero wants to merge 2 commits into
Open
fix(anthropic): serialize Claude Code OAuth refresh under the auth-store lock#60639loop2zero wants to merge 2 commits into
loop2zero wants to merge 2 commits into
Conversation
…th-store lock The credential-pool anthropic claude_code refresh path synced tokens from ~/.claude/.credentials.json and then POSTed the refresh_token to Anthropic without holding the cross-process auth-store lock across the whole read->POST->write-back sequence. Because Claude Code OAuth refresh tokens are single-use, concurrent Hermes turns (multiple gateway sessions, delegation subagents, cron jobs) could each adopt the same on-disk token and POST it; the losers 429 the refresh endpoint. With a single Anthropic credential in the pool, the entry is then marked exhausted and every live turn silently falls back to another provider even though the Claude subscription is healthy. Wrap the anthropic claude_code branch of _refresh_entry in the existing shared _auth_store_lock (reentrant cross-process flock), mirroring the Codex OAuth fix in NousResearch#56233. A waiter now blocks on the lock and, once inside, the in-lock re-sync from the credentials file adopts the token the winner rotated and skips its own POST. Tests: tests/agent/test_credential_pool.py (87 passed) incl. two new regressions asserting the lock is taken and that a resync short-circuits the POST.
…auth-store lock _refresh_oauth_token() is a second, older Claude Code OAuth refresh path (predates credential_pool) that the NousResearch#56233 Codex fix and the pool-side lock do not cover. It is invoked on every anthropic_messages API call via _try_refresh_anthropic_client_credentials() (run_agent.py) and by auxiliary_client refresh — for the main agent, delegation subagents and cron jobs alike. It POSTed the single-use refresh token with no cross-process serialization, so concurrent turns replayed the same token, 429d Anthropic's refresh endpoint, exhausted the sole Anthropic credential and forced a silent fallback to another provider. Wrap the read->POST->write-back in the same reentrant shared _auth_store_lock (so the pool refresh path that already holds it does not deadlock) and re-read the live credential file inside the lock: a waiter adopts the token the winner already rotated instead of re-spending a consumed one. Falls back to the unserialized path only if the lock helper is unavailable. Together with the pool-side commit this covers all Claude Code OAuth refresh entry points. Tests: tests/agent/test_anthropic_adapter.py (175 passed, isolated HOME) incl. two new regressions asserting the refresh runs under the lock and that a concurrently-rotated token is adopted without re-POSTing.
Contributor
|
Thanks for tracing both current refresh paths; the underlying race is present on current main ( Problems
Suggested changes
Automated hermes-sweeper review. |
This was referenced Jul 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Native Anthropic (
model.provider: anthropic, Claude Code OAuth) silently falls back to another provider under concurrent load, even when the Claude subscription is healthy andclaude -p --model ...works directly. Every gateway/Discord turn logs:and
hermes auth list anthropicshows the soleclaude_codeentry asrate-limited (429).Root cause
Claude Code OAuth refresh tokens are single-use. Multiple concurrent Hermes turns (several gateway sessions, delegation subagents, cron jobs) each POST the same refresh token to Anthropic's token endpoint, invalidate one another, and drive the endpoint into 429s. With a single Anthropic credential in the pool, the entry is then marked exhausted and every live turn routes to the fallback provider — silently, for up to the cooldown TTL.
This is the same single-use-refresh-token concurrency race that #56233 fixed for Codex:
That fix wrapped the Codex branch of
_refresh_entryin the shared_auth_store_lockbut was deliberately scoped narrow ("this ships the narrow lock-serialization fix") and never generalized. Anthropic Claude Code OAuth has two refresh paths with the same shape, neither serialized:_refresh_entry_refresh_oauth_tokenWhy the asymmetry existed:
anthropic_adapter._refresh_oauth_tokenpredates the credential pool. The direct adapter refresh landed 2026-03-12;credential_pool.py(which owns_auth_store_lock) landed 2026-03-31. The direct path was written around Claude Code's own~/.claude/.credentials.jsonfile flow and never learned about the later auth-store lock.auth.json(providers.openai-codex), already under_auth_store_lock, so "wrap it in the existing lock" was the natural Codex fix. Anthropicclaude_codetokens live in Claude Code's~/.claude/.credentials.json, historically guarded only by a file-sync mechanism (_sync_anthropic_entry_from_credentials_file) — which narrows the race window but cannot eliminate it for a single-use token.Changes
Both commits reuse the exact pattern established by #56233 — the existing reentrant, cross-process
_auth_store_lock, with an in-lock re-read so a waiter adopts the token the winner rotated instead of re-POSTing a consumed one.Commit 1 — pool refresh path (
agent/credential_pool.py)Wrap the
provider == "anthropic" and source == "claude_code"branch of_refresh_entryin_auth_store_lock, re-syncing from the credentials file inside the lock. Directly mirrors the Codex block added in #56233.Commit 2 — direct refresh path (
agent/anthropic_adapter.py)Serialize
_refresh_oauth_token's read→POST→write-back under the same reentrant lock, with an in-lock re-read that adopts a concurrently-rotated token and skips the POST. This path is hit on everyanthropic_messagesAPI call via_try_refresh_anthropic_client_credentials()and byauxiliary_clientrefresh, so it covers the main agent, delegation subagents, and cron jobs. Falls back to the unserialized path only if the lock helper is unavailable.Together these cover all Claude Code OAuth refresh entry points.
Reproduction
claude_codecredential).auth list anthropicflips torate-limited (429), and every subsequent turn logsPrimary provider auth failed ... Fallback provider resolved: openai-codex, running Claude-configured sessions on the fallback model instead.Testing
pytest tests/agent/test_credential_pool.py— 87 passed (incl. 2 new regressions: refresh runs under the lock; a resync short-circuits the POST).pytest tests/agent/test_anthropic_adapter.py— 175 passed with isolatedHOME(incl. 2 new regressions: direct refresh runs under the lock; a concurrently-rotated token is adopted without re-POSTing).main: 262 passed.Verified live on a running gateway after applying both commits: main agent, delegation subagents (
platform=subagent), and an agent-mode cron job all logprovider=anthropicwith zeroFallback provider resolved/Runtime provider supplied explicit model overridelines, and the pooled credential stays active instead of flipping torate-limited.Related