{"repo":"NousResearch/hermes-agent","public_id":"58013","display_label":"Anthropic OAuth token exchange 429 retry fixes","summary":"These issues and PRs address a bug where HTTP 429 rate-limit responses during Anthropic OAuth token exchange permanently invalidate the single-use authorization code with no retry. Fixes cover retrying on 429, correcting the User-Agent header, and bounding OAuth token responses.","subsystem":"agent","counts":{"members":5,"open_issues":1,"open_issues_distinct":1,"open_prs":1,"closed_issues":0,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":58178,"merged_at":"2026-07-04T09:46:13+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-04T09:46:13+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":48534,"pr_number":58178,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-04T09:46:13+00:00"},{"issue_number":58013,"pr_number":58178,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-04T09:46:13+00:00"}],"members":[{"kind":"issue","number":58013,"title":"[Bug]: Anthropic OAuth login: transient HTTP 429 on token exchange burns the single-use authorization code (no retry)","url":null,"state":"open"},{"kind":"pr","number":54798,"title":"fix(anthropic): bound OAuth token responses","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":58014,"title":"fix(auth): retry Anthropic OAuth code exchange on HTTP 429 instead of burning the authorization code","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#58178","number":58178}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":58178,"title":"fix(anthropic): OAuth token endpoint UA must not be claude-code/ (login 429, #48534)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/anthropic_adapter.py","tests/agent/test_anthropic_adapter.py","tests/agent/test_anthropic_oauth_ua_prefix.py"],"fix_hunks":[{"file":"agent/anthropic_adapter.py","hunks":["@@ -1045,7 +1045,7 @@ def refresh_anthropic_oauth_pure(refresh_token: str, *, use_json: bool = False)\n             data=data,\n             headers={\n                 \"Content-Type\": content_type,\n-                \"User-Agent\": f\"claude-code/{_get_claude_code_version()} (external, cli)\",\n+                \"User-Agent\": _OAUTH_TOKEN_USER_AGENT,\n             },\n             method=\"POST\",\n         )","@@ -1378,6 +1378,16 @@ def run_oauth_setup_token() -> Optional[str]:\n     \"https://console.anthropic.com/v1/oauth/token\",\n ]\n _OAUTH_TOKEN_URL = [redacted:secret-assignment]\n+# User-Agent sent on the OAuth *token endpoint* (login exchange + refresh).\n+# Anthropic rate-limits (HTTP 429) any token-endpoint request whose UA starts\n+# with ``claude-code/`` — verified empirically against platform.claude.com:\n+# ``claude-code/2.1.200`` and ``Mozilla/5.0`` -> 429; ``axios/*``, ``node``,\n+# and SDK-style UAs -> 400 (reached code validation). The real Claude Code CLI\n+# exchanges the auth code with a bare axios client (``axios/<ver>``), NOT its\n+# ``claude-code/`` inference UA. We mirror that here. NOTE: the *inference* path\n+# (build_anthropic_kwargs) still uses the ``claude-code/`` UA + ``x-app: cli`` —\n+# that fingerprint is required there and is NOT throttled on the messages API.\n+_OAUTH_TOKEN_USER_AGENT = \"[redacted:secret-assignment]\"\n _OAUTH_REDIRECT_URI = \"https://console.anthropic.com/oauth/code/callback\"\n _OAUTH_SCOPES = \"org:create_api_key user:profile user:inference\"\n _HERMES_OAUTH_FILE = get_hermes_home() / \".anthropic_oauth.json\"","@@ -1478,8 +1488,9 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:\n         # Anthropic migrated the OAuth token endpoint to platform.claude.com;\n         # console.anthropic.com now 404s. Try the new host first, then fall\n         # back to console for older deployments (mirrors the refresh path).\n-        # Use the claude-code/ UA prefix: Anthropic blocks claude-cli/ on the\n-        # OAuth token endpoint (returns 404 for all versions).\n+        # UA is _OAUTH_TOKEN_USER_AGENT (a non-claude-code UA) — see the\n+        # constant's definition for why the token endpoint must not send\n+        # claude-code/ (429 UA-prefix block).\n         result = None\n         last_error = None\n         for endpoint in _OAUTH_TOKEN_URLS:","@@ -1488,7 +1499,7 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:\n                 data=exchange_data,\n                 headers={\n                     \"Content-Type\": \"application/json\",\n-                    \"User-Agent\": f\"claude-code/{_get_claude_code_version()} (external, cli)\",\n+                    \"User-Agent\": _OAUTH_TOKEN_USER_AGENT,\n                 },\n                 method=\"POST\",\n             )"]},{"file":"tests/agent/test_anthropic_adapter.py","hunks":["@@ -507,12 +507,22 @@ def test_keeps_static_anthropic_token_when_only_non_refreshable_claude_key_exist\n \n \n class TestRefreshOauthToken:\n-    def test_returns_none_without_refresh_token(self):\n+    def test_returns_none_without_refresh_token(self, tmp_path, monkeypatch):\n+        monkeypatch.setattr(\"agent.anthropic_adapter.Path.home\", lambda: tmp_path)\n+        # Neutralize live Claude Code sources (macOS Keychain + ~/.claude file)\n+        # so the adopt-already-refreshed branch can't short-circuit with a real\n+        # credential on a dev/CI machine that happens to have Claude Code creds.\n+        monkeypatch.setattr(\n+            \"agent.anthropic_adapter.read_claude_code_credentials\", lambda: None\n+        )\n         creds = {\"accessToken\": \"[redacted:secret-assignment]\", \"refreshToken\": \"\", \"expiresAt\": 0}\n         assert _refresh_oauth_token(creds) is None\n \n     def test_successful_refresh(self, tmp_path, monkeypatch):\n         monkeypatch.setattr(\"agent.anthropic_adapter.Path.home\", lambda: tmp_path)\n+        monkeypatch.setattr(\n+            \"agent.anthropic_adapter.read_claude_code_credentials\", lambda: None\n+        )\n \n         creds = {\n             \"accessToken\": \"[redacted:secret-assignment]\",","@@ -544,7 +554,11 @@ def test_successful_refresh(self, tmp_path, monkeypatch):\n         assert written[\"claudeAiOauth\"][\"accessToken\"] == \"new-token-abc\"\n         assert written[\"claudeAiOauth\"][\"refreshToken\"] == \"new-refresh-456\"\n \n-    def test_failed_refresh_returns_none(self):\n+    def test_failed_refresh_returns_none(self, tmp_path, monkeypatch):\n+        monkeypatch.setattr(\"agent.anthropic_adapter.Path.home\", lambda: tmp_path)\n+        monkeypatch.setattr(\n+            \"agent.anthropic_adapter.read_claude_code_credentials\", lambda: None\n+        )\n         creds = {\n             \"accessToken\": \"old\",\n             \"refreshToken\": \"[redacted:secret-assignment]\","]},{"file":"tests/agent/test_anthropic_oauth_ua_prefix.py","hunks":["@@ -1,7 +1,16 @@\n \"\"\"Regression tests for the OAuth User-Agent header in anthropic_adapter.py.\n \n-Anthropic now 404s the OAuth token endpoint for any ``claude-cli/`` UA prefix\n-(issue #48534). The adapter must use ``claude-code/`` instead.\n+Two DIFFERENT Anthropic endpoints impose OPPOSITE User-Agent requirements:\n+\n+- Inference (``/v1/messages`` via build_anthropic_client): requires the\n+  ``claude-code/`` UA + ``x-app: cli`` fingerprint, or requests get\n+  intermittent 500s. (issue #48534: ``claude-cli/`` is 404'd here.)\n+- OAuth token endpoint (``/v1/oauth/token`` login exchange + refresh):\n+  Anthropic now RATE-LIMITS (HTTP 429) any UA whose prefix is ``claude-code/``\n+  (or ``Mozilla/``). Verified empirically against platform.claude.com:\n+  ``claude-code/2.1.200`` -> 429; ``axios/*`` / ``node`` -> 400 (reached code\n+  validation). The token endpoint must therefore use a non-``claude-code/`` UA\n+  (we send ``axios/*``, matching the real Claude Code CLI's exchange client).\n \"\"\"\n \n from __future__ import annotations","@@ -13,10 +22,10 @@\n \n \n class TestOAuthUserAgentPrefix:\n-    \"\"\"All OAuth-related HTTP requests must use ``claude-code/`` UA, not ``claude-cli/``.\"\"\"\n+    \"\"\"Inference uses ``claude-code/``; the OAuth token endpoint must NOT.\"\"\"\n \n     def test_build_anthropic_client_oauth_ua(self):\n-        \"\"\"build_anthropic_client with OAuth token must use claude-code UA.\"\"\"\n+        \"\"\"build_anthropic_client (INFERENCE) with OAuth token must use claude-code UA.\"\"\"\n         from agent.anthropic_adapter import build_anthropic_client\n \n         mock_sdk = MagicMock()","@@ -47,33 +56,40 @@ def test_no_claude_cli_in_source(self):\n                     f\"Line {i}: claude-cli/ still used in User-Agent header: {stripped}\"\n                 )\n \n-    def test_token_exchange_ua_prefix(self):\n-        \"\"\"run_hermes_oauth_login_pure must not send claude-cli/ UA.\"\"\"\n+    def test_token_exchange_ua_not_throttled(self):\n+        \"\"\"run_hermes_oauth_login_pure must NOT send a throttled token-endpoint UA.\n+\n+        Anthropic 429s both ``claude-cli/`` and ``claude-code/`` UAs at the\n+        token endpoint. The login exchange must use the shared\n+        ``_OAUTH_TOKEN_USER_AGENT`` constant (a non-claude-code UA).\n+        \"\"\"\n         import inspect\n         import agent.anthropic_adapter as mod\n \n-        # Get the source of the exchange function\n         try:\n             source = inspect.getsource(mod.run_hermes_oauth_login_pure)\n         except AttributeError:\n             pytest.skip(\"run_hermes_oauth_login_pure not found\")\n \n-        # Only fail on claude-cli/ in an actual User-Agent header line — a\n-        # comment that references the old behavior (e.g. \"Anthropic blocks\n-        # claude-cli/ on the OAuth endpoint\") is allowed. Mirrors the\n-        # header-scoped check in test_no_claude_cli_in_source.\n         for i, line in enumerate(source.split(\"\\n\"), 1):\n             stripped = line.strip()\n-            if \"claude-cli/\" in stripped and (\"User-Agent\" in stripped or \"user-agent\" in stripped):\n+            if (\"User-Agent\" in stripped or \"user-agent\" in stripped) and (\n+                \"claude-cli/\" in stripped or \"claude-code/\" in stripped\n+            ):\n                 pytest.fail(\n-                    f\"Line {i}: run_hermes_oauth_login_pure still uses claude-cli/ UA header: {stripped}\"\n+                    f\"Line {i}: throttled UA in token-exchange header: {stripped}\"\n                 )\n-        assert \"claude-code/\" in source, (\n-            \"run_hermes_oauth_login_pure should use claude-code/ UA\"\n+        assert \"_OAUTH_TOKEN_USER_AGENT\" in source, (\n+            \"run_hermes_oauth_login_pure should send the shared \"\n+            \"_OAUTH_TOKEN_USER_AGENT (non-claude-code) on the token endpoint\"\n+        )\n+        assert not mod._OAUTH_TOKEN_USER_AGENT.startswith((\"claude-code/\", \"claude-cli/\")), (\n+            f\"_OAUTH_TOKEN_USER_AGENT must not be a throttled prefix: \"\n+            f\"{mod._OAUTH_TOKEN_USER_AGENT!r}\"\n         )\n \n-    def test_token_refresh_ua_prefix(self):\n-        \"\"\"refresh_anthropic_oauth_pure must not send claude-cli/ UA.\"\"\"\n+    def test_token_refresh_ua_not_throttled(self):\n+        \"\"\"refresh_anthropic_oauth_pure must NOT send a throttled token-endpoint UA.\"\"\"\n         import inspect\n         import agent.anthropic_adapter as mod\n ","@@ -82,13 +98,15 @@ def test_token_refresh_ua_prefix(self):\n             pytest.skip(\"refresh_anthropic_oauth_pure not found\")\n         source = inspect.getsource(func)\n \n-        # Header-scoped check (comments referencing claude-cli/ are allowed).\n         for i, line in enumerate(source.split(\"\\n\"), 1):\n             stripped = line.strip()\n-            if \"claude-cli/\" in stripped and (\"User-Agent\" in stripped or \"user-agent\" in stripped):\n+            if (\"User-Agent\" in stripped or \"user-agent\" in stripped) and (\n+                \"claude-cli/\" in stripped or \"claude-code/\" in stripped\n+            ):\n                 pytest.fail(\n-                    f\"Line {i}: refresh_anthropic_oauth_pure still uses claude-cli/ UA header: {stripped}\"\n+                    f\"Line {i}: throttled UA in refresh header: {stripped}\"\n                 )\n-        assert \"claude-code/\" in source, (\n-            \"refresh_anthropic_oauth_pure should use claude-code/ UA\"\n+        assert \"_OAUTH_TOKEN_USER_AGENT\" in source, (\n+            \"refresh_anthropic_oauth_pure should send the shared \"\n+            \"_OAUTH_TOKEN_USER_AGENT (non-claude-code) on the token endpoint\"\n         )\n"]}]},{"kind":"pr","number":58212,"title":"fix(anthropic): retry oauth code exchange rate limits","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4c5b4417bbb65cfbe92f5a59b8e23dc72c016200","number":0},{"kind":"commit","value":"09693cd3a339d61a0f461e59d0a23b5a33479185","number":0},{"kind":"pr","value":"#58178","number":58178},{"kind":"pr","value":"#58014","number":58014}],"category":"merged_via","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}