fix(auth): retry Codex device-code login on 429 with clear rate-limit message (#47860)

The OpenAI device-code login (POST auth.openai.com/.../deviceauth/usercode)
had no retry or 429 handling — a transient throttle from OpenAI surfaced as
a bare "Device code request returned status 429" with no guidance, reading
as a hard login failure.

- Retry the device-code request with capped exponential backoff (honoring
  Retry-After), up to 4 attempts.
- On persistent 429, raise a clear AuthError tagged CODEX_RATE_LIMITED_CODE
  (classified transient, not a credential problem) with a wait hint.
- Apply the same 429 classification to the token-exchange step (same bug
  class).

Unrelated to PR #47399 (Responses-API cache headers); this is the OAuth
device-code path in hermes_cli/auth.py.
This commit is contained in:
Teknium 2026-06-17 05:48:35 -07:00 committed by GitHub
parent 06d907dc4e
commit cbfa018aef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 150 additions and 12 deletions

View file

@ -1009,3 +1009,87 @@ def test_login_openai_codex_force_new_login_skips_existing_reuse_prompt(monkeypa
assert called["device_login"] == 1
assert called["tokens"]["access_token"] == "fresh-at"
class _FakeResp:
def __init__(self, status_code, json_data=None, headers=None):
self.status_code = status_code
self._json = json_data or {}
self.headers = headers or {}
def json(self):
return self._json
def _patch_httpx_post(monkeypatch, responses):
"""Patch hermes_cli.auth.httpx.Client so .post() returns queued responses."""
seq = iter(responses)
class _FakeClient:
def __enter__(self):
return self
def __exit__(self, *a):
return False
def post(self, *args, **kwargs):
return next(seq)
monkeypatch.setattr("hermes_cli.auth.httpx.Client", lambda *a, **k: _FakeClient())
def test_device_code_login_retries_on_429_then_succeeds(monkeypatch):
"""A transient 429 on the device-code request is retried, not surfaced."""
from hermes_cli import auth as auth_mod
sleeps = []
monkeypatch.setattr("time.sleep", lambda s: sleeps.append(s))
# First call 429 (with Retry-After), second call succeeds. The polling
# loop then returns the authorization code, and token exchange succeeds.
_patch_httpx_post(
monkeypatch,
[
_FakeResp(429, headers={"retry-after": "1"}),
_FakeResp(200, {"user_code": "ABCD", "device_auth_id": "dev-1", "interval": "5"}),
_FakeResp(200, {"authorization_code": "auth-code", "code_verifier": "verifier"}),
_FakeResp(200, {"access_token": "at", "refresh_token": "rt", "expires_in": 3600}),
],
)
# Skip the polling sleep too (shares time.sleep, already patched).
creds = auth_mod._codex_device_code_login()
assert creds["tokens"]["access_token"] == "at"
# The 429 caused exactly one backoff sleep before the retry succeeded.
assert 1 in sleeps
def test_device_code_login_persistent_429_raises_rate_limited(monkeypatch):
"""A persistent 429 surfaces a clear rate-limit error, not a bare status."""
from hermes_cli import auth as auth_mod
monkeypatch.setattr("time.sleep", lambda s: None)
_patch_httpx_post(monkeypatch, [_FakeResp(429, headers={"retry-after": "30"})] * 4)
with pytest.raises(AuthError) as exc_info:
auth_mod._codex_device_code_login()
err = exc_info.value
assert err.code == auth_mod.CODEX_RATE_LIMITED_CODE
assert "rate-limiting" in str(err)
assert "30s" in str(err)
assert auth_mod.is_rate_limited_auth_error(err)
def test_device_code_login_non_429_error_unchanged(monkeypatch):
"""Non-429 failures keep the generic device_code_request_error code."""
from hermes_cli import auth as auth_mod
monkeypatch.setattr("time.sleep", lambda s: None)
_patch_httpx_post(monkeypatch, [_FakeResp(500)])
with pytest.raises(AuthError) as exc_info:
auth_mod._codex_device_code_login()
assert exc_info.value.code == "device_code_request_error"