Skip to content

Commit 1ea8412

Browse files
authored
fix: credential pool 401 recovery rotates to next credential after failed refresh (NousResearch#4300)
When an OAuth token refresh fails on a 401 error, the pool recovery would return 'not recovered' without trying the next credential in the pool. This meant users who added a second valid credential via 'hermes auth add' would never see it used when the primary credential was dead. Now: try refresh first (handles expired tokens quickly), and if that fails, rotate to the next available credential — same as 429/402 already did. Adds three tests covering 401 refresh success, refresh-fail-then-rotate, and refresh-fail-with-no-remaining-credentials.
1 parent 1b2ac24 commit 1ea8412

2 files changed

Lines changed: 72 additions & 0 deletions

File tree

run_agent.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3862,6 +3862,13 @@ def _recover_with_credential_pool(
38623862
logger.info(f"Credential 401 — refreshed pool entry {getattr(refreshed, 'id', '?')}")
38633863
self._swap_credential(refreshed)
38643864
return True, has_retried_429
3865+
# Refresh failed — rotate to next credential instead of giving up.
3866+
# The failed entry is already marked exhausted by try_refresh_current().
3867+
next_entry = pool.mark_exhausted_and_rotate(status_code=401)
3868+
if next_entry is not None:
3869+
logger.info(f"Credential 401 (refresh failed) — rotated to pool entry {getattr(next_entry, 'id', '?')}")
3870+
self._swap_credential(next_entry)
3871+
return True, False
38653872

38663873
return False, has_retried_429
38673874

tests/test_run_agent.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1848,6 +1848,71 @@ def mark_exhausted_and_rotate(self, *, status_code):
18481848
agent._swap_credential.assert_called_once_with(next_entry)
18491849

18501850

1851+
def test_recover_with_pool_refreshes_on_401(self, agent):
1852+
"""401 with successful refresh should swap to refreshed credential."""
1853+
refreshed_entry = SimpleNamespace(label="refreshed-primary", id="abc")
1854+
1855+
class _Pool:
1856+
def try_refresh_current(self):
1857+
return refreshed_entry
1858+
1859+
agent._credential_pool = _Pool()
1860+
agent._swap_credential = MagicMock()
1861+
1862+
recovered, retry_same = agent._recover_with_credential_pool(
1863+
status_code=401,
1864+
has_retried_429=False,
1865+
)
1866+
1867+
assert recovered is True
1868+
agent._swap_credential.assert_called_once_with(refreshed_entry)
1869+
1870+
def test_recover_with_pool_rotates_on_401_when_refresh_fails(self, agent):
1871+
"""401 with failed refresh should rotate to next credential."""
1872+
next_entry = SimpleNamespace(label="secondary", id="def")
1873+
1874+
class _Pool:
1875+
def try_refresh_current(self):
1876+
return None # refresh failed
1877+
1878+
def mark_exhausted_and_rotate(self, *, status_code):
1879+
assert status_code == 401
1880+
return next_entry
1881+
1882+
agent._credential_pool = _Pool()
1883+
agent._swap_credential = MagicMock()
1884+
1885+
recovered, retry_same = agent._recover_with_credential_pool(
1886+
status_code=401,
1887+
has_retried_429=False,
1888+
)
1889+
1890+
assert recovered is True
1891+
assert retry_same is False
1892+
agent._swap_credential.assert_called_once_with(next_entry)
1893+
1894+
def test_recover_with_pool_401_refresh_fails_no_more_credentials(self, agent):
1895+
"""401 with failed refresh and no other credentials returns not recovered."""
1896+
1897+
class _Pool:
1898+
def try_refresh_current(self):
1899+
return None
1900+
1901+
def mark_exhausted_and_rotate(self, *, status_code):
1902+
return None # no more credentials
1903+
1904+
agent._credential_pool = _Pool()
1905+
agent._swap_credential = MagicMock()
1906+
1907+
recovered, retry_same = agent._recover_with_credential_pool(
1908+
status_code=401,
1909+
has_retried_429=False,
1910+
)
1911+
1912+
assert recovered is False
1913+
agent._swap_credential.assert_not_called()
1914+
1915+
18511916
class TestMaxTokensParam:
18521917
"""Verify _max_tokens_param returns the correct key for each provider."""
18531918

0 commit comments

Comments
 (0)