Codex 429 quota errors misclassified as auth failures
Cluster 32790 · gateway. Data as of
2 open items. Fix merged in #33168 on 27 May 2026. Check it covers your case.
The short answer
- Known?
- Yes. 7 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #33168. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- 1 issue and 1 PR.
Next step: read #33168, then check whether your report is the one still open.
Read #33168Pick up a Lab taskMultiple fixes addressing a bug where upstream provider 429 rate-limit or quota exhaustion responses were incorrectly surfaced as authentication or credential failures. Work spans gateway error classification, OAuth error parsing, and rate-limit state preservation.
Open issues1
- Issue#60846bug(gateway): provider 429 rate-limit surfaces as "Provider authentication failed" when credentials are valid
Open PRs1
- PR#60856Fix gateway 429 provider error classification
Fixes on record
-
#33168 fix(gateway): classify Codex 429 quota as rate-limit, not missing credentials
files:
gateway/run.py,hermes_cli/auth.py,tests/gateway/test_verbose_command.py,tests/hermes_cli/test_auth_codex_provider.pyShow the diff
-
gateway/run.py@@ -1078,14 +1078,19 @@ def _resolve_runtime_agent_kwargs() -> dict: resolve_runtime_provider, format_runtime_provider_error, ) - from hermes_cli.auth import AuthError + from hermes_cli.auth import AuthError, is_rate_limited_auth_error try: runtime = resolve_runtime_provider() except AuthError as auth_exc: - # Primary provider auth failed (expired token, revoked key, etc.). - # Try the fallback provider chain before raising. - logger.warning("Primary provider auth failed: %s — trying fallback", auth_exc) + # Distinguish a transient rate-limit/quota cap (credentials are fine, + # re-auth cannot help) from a genuine auth failure (expired/revoked + # token). Both fall through to the fallback chain, but the log message + # must not mislabel a quota exhaustion as an auth failure (#32790). + if is_rate_limited_auth_error(auth_exc): + logger.warning("Primary provider rate-limited (429): %s — trying fallback", auth_exc) + else: + logger.warning("Primary provider auth failed: %s — trying fallback", auth_exc) fb_config = _try_resolve_fallback_provider() if fb_config is not None: return fb_config @@ -1131,9 +1136,13 @@ def _try_resolve_fallback_provider() -> dict | None: explicit_base_url=entry.get("base_url"), explicit_api_key=[redacted:secret-assignment] ) + # Log the literal `provider` key from config, not the resolved + # runtime category — an Ollama fallback resolves through the + # OpenAI-compatible path and would otherwise be logged as + # "openrouter", contradicting the operator's config (#32790). logger.info( "Fallback provider resolved: %s model=%s", - runtime.get("provider"), + entry.get("provider") or runtime.get("provider"), entry.get("model"), ) return { -
hermes_cli/auth.py@@ -729,6 +729,12 @@ def _resolve_zai_base_url(api_key: str, default_url: str, env_override: str) -> # Error Types # ============================================================================= +# Error code marking upstream rate-limit / usage-quota exhaustion (HTTP 429). +# Such failures are transient and re-authenticating cannot resolve them, so +# they must be kept distinct from missing/expired-credential errors. +CODEX_RATE_LIMITED_CODE = "codex_rate_limited" + + class AuthError(RuntimeError): """Structured auth error with UX mapping hints.""" @@ -746,11 +752,52 @@ def __init__( self.relogin_required = relogin_required +def is_rate_limited_auth_error(error: Exception) -> bool: + """True when an :class:`AuthError` represents upstream rate-limiting / quota + exhaustion rather than missing or invalid credentials. + + These failures are transient — re-authenticating cannot resolve them — so + callers should surface a "retry later" notice and prefer a fallback chain + instead of prompting the operator to run ``hermes auth``. + """ + return ( + isinstance(error, AuthError) + and not error.relogin_required + and error.code == CODEX_RATE_LIMITED_CODE + ) + + +def _parse_retry_after_seconds(headers: Any) -> Optional[int]: + """Best-effort parse of a ``Retry-After`` header into whole seconds. + + Supports the delta-seconds form (e.g. ``"120"``). HTTP-date forms and + missing/unparseable values return ``None`` rather than guessing. + """ + if headers is None: + return None + try: + raw = headers.get("retry-after") + except Exception: + return None + if raw is None: + return None + try: + seconds = int(str(raw).strip()) + except (TypeError, ValueError): + return None + return seconds if seconds >= 0 else None + + def format_auth_error(error: Exception) -> str: """Map auth failures to concise user-facing guidance.""" if not isinstance(error, AuthError): return str(error) + # Rate-limit / quota errors are not credential problems — never append the + # "re-authenticate" remediation, which would mislead the operator. + if is_rate_limited_auth_error(error): + return str(error) + if error.relogin_required: return f"{error} Run `hermes model` to re-authenticate." @@ -3308,6 +3355,30 @@ def refresh_codex_oauth_pure( }, ) + if response.status_code == 429: + # Upstream rate-limit / usage-quota exhaustion on the token endpoint. + # The stored refresh token is still valid here — re-authenticating + # cannot lift a quota cap. Classify distinctly from auth failures so + # callers surface a "retry later" notice instead of a misleading + # "run hermes auth" prompt (see issue #32790). + retry_after = _parse_retry_after_seconds(getattr(response, "headers", None)) + if retry_after is not None: + message = ( + f"Codex provider quota exhausted (429); retry after {retry_after}s. " + "Credentials are still valid." + ) + else: + message = ( + "Codex provider quota exhausted (429). Credentials are still valid; " + "retry after the usage limit resets." + ) + raise AuthError( + message, + provider="openai-codex", + code=CODEX_RATE_LIMITED_CODE, + relogin_required=False, + ) + if response.status_code != 200: code = "codex_refresh_failed" message = f"Codex token refresh failed with status {response.status_code}." -
tests/gateway/test_verbose_command.py@@ -128,8 +128,14 @@ async def test_cycles_through_all_modes(self, tmp_path, monkeypatch): f"Expected {mode}, got {actual}" @pytest.mark.asyncio - async def test_defaults_to_all_when_no_tool_progress_set(self, tmp_path, monkeypatch): - """When tool_progress is not in config, defaults to platform default then cycles.""" + async def test_defaults_to_platform_default_when_no_tool_progress_set(self, tmp_path, monkeypatch): + """When tool_progress is not in config, starts from platform default then cycles. + + Telegram's tier-1 preset overrides ``tool_progress`` to ``"off"`` so the + platform stays final-answer-first by default on mobile inboxes. The + first ``/verbose`` invocation therefore cycles ``off → new``, not + ``all → ...``. + """ hermes_home = tmp_path / "hermes" hermes_home.mkdir() config_path = hermes_home / "config.yaml" @@ -143,17 +149,18 @@ async def test_defaults_to_all_when_no_tool_progress_set(self, tmp_path, monkeyp runner = _make_runner() result = await runner._handle_verbose_command(_make_event()) - # Telegram platform default is "new" → cycles to "all" - assert "ALL" in result + # Telegram platform default is "off" → cycles to "new" + assert "NEW" in result saved = yaml.safe_load(config_path.read_text(encoding="utf-8")) - assert saved["display"]["platforms"]["telegram"]["tool_progress"] == "all" + assert saved["display"]["platforms"]["telegram"]["tool_progress"] == "new" @pytest.mark.asyncio async def test_per_platform_isolation(self, tmp_path, monkeypatch): """Cycling /verbose on Telegram doesn't change Slack's setting. Without a global tool_progress, each platform uses its built-in - default: Telegram = 'new' (overridden high tier), Slack = 'off' (quiet Slack default). + default — Telegram = 'off' (tier-1 inbox override), Slack = 'off' + (quiet Slack default). Both cycle to 'new' on first /verbose. """ hermes_home = tmp_path / "hermes" hermes_home.mkdir() @@ -178,8 +185,8 @@ async def test_per_platform_isolation(self, tmp_path, monkeypatch): saved = yaml.safe_load(config_path.read_text(encoding="utf-8")) platforms = saved["display"]["platforms"] - # Telegram: new -> all (platform default = new) - assert platforms["telegram"]["tool_progress"] == "all" + # Telegram: off -> new (platform default = off, tier-1 inbox override) + assert platforms["telegram"]["tool_progress"] == "new" # Slack: off -> new (first /verbose cycle from quiet default) assert platforms["slack"]["tool_progress"] == "new" -
tests/hermes_cli/test_auth_codex_provider.py@@ -263,9 +263,10 @@ def test_resolve_returns_hermes_auth_store_source(tmp_path, monkeypatch): class _StubHTTPResponse: - def __init__(self, status_code: int, payload): + def __init__(self, status_code: int, payload, headers=None): self.status_code = status_code self._payload = payload + self.headers = headers or {} self.text = json.dumps(payload) if isinstance(payload, (dict, list)) else str(payload) def json(self): @@ -382,6 +383,74 @@ def test_refresh_falls_back_to_generic_message_on_unparseable_body(monkeypatch): assert "status 401" in str(err) +def test_refresh_429_classified_as_quota_not_auth_failure(monkeypatch): + """429 from the token endpoint is a usage-quota cap, not an auth failure. + + Regression test for #32790: must NOT force relogin and must carry the + dedicated rate-limit code so callers surface a "retry later" notice rather + than a misleading "run hermes auth". + """ + from hermes_cli.auth import ( + CODEX_RATE_LIMITED_CODE, + format_auth_error, + is_rate_limited_auth_error, + ) + + response = _StubHTTPResponse( + 429, + {"error": {"message": "You hit your usage limit.", "code": "usage_limit_reached"}}, + headers={"retry-after": "120"}, + ) + _patch_httpx(monkeypatch, response) + + with pytest.raises(AuthError) as exc_info: + refresh_codex_oauth_pure("a-tok", "r-tok") + + err = exc_info.value + assert err.code == CODEX_RATE_LIMITED_CODE + assert err.relogin_required is False + assert is_rate_limited_auth_error(err) is True + assert "retry after 120s" in str(err) + # User-facing copy must not tell the operator to re-authenticate. + rendered = format_auth_error(err) + assert "re-authenticate" not in rendered + assert "hermes auth" not in rendered + + +def test_refresh_429_without_retry_after_header(monkeypatch): + """429 without a Retry-After header still classifies as quota, no relogin.""" + from hermes_cli.auth import CODEX_RATE_LIMITED_CODE + + response = _StubHTTPResponse(429, {"error": "rate_limited"}) + _patch_httpx(monkeypatch, response) + + with pytest.raises(AuthError) as exc_info: + refresh_codex_oauth_pure("a-tok", "r-tok") + + err = exc_info.value + assert err.code == CODEX_RATE_LIMITED_CODE + assert err.relogin_required is False + assert "quota exhausted" in str(err).lower() + + +def test_is_rate_limited_auth_error_distinguishes_credential_errors(): + """Missing/expired credentials must NOT be treated as rate-limit errors.""" + from hermes_cli.auth import CODEX_RATE_LIMITED_CODE, is_rate_limited_auth_error + + rate_limited = AuthError( + "quota", provider="openai-codex", code=CODEX_RATE_LIMITED_CODE, relogin_required=False + ) + missing_creds = AuthError( + "No Codex credentials stored.", + provider="openai-codex", + code="codex_auth_missing", + relogin_required=True, + ) + assert is_rate_limited_auth_error(rate_limited) is True + assert is_rate_limited_auth_error(missing_creds) is False + assert is_rate_limited_auth_error(ValueError("nope")) is False + + def test_login_openai_codex_force_new_login_skips_existing_reuse_prompt(monkeypatch): called = {"device_login": 0}
-
3 PRs closed without merging
From the close comments: PR volume cleanup 1 · duplicate 1 · superseded by another PR 1
-
#33779 fix(gateway): separate 429 quota errors from auth failures (#32790) · superseded by another PR →
#33168,#32790,#33780 -
#33780 fix(gateway): separate 429 quota errors from auth failures (#32790) · duplicate →
#33168,#32790,#32881 - #46643 fix(gateway): consolidate Codex quota error handling · PR volume cleanup
Closed issues · 1
- Closed#32790Hermes mislabels upstream 429 quota exhaustion as missing Codex credentials
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.26: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- gateway
- Merged PRs
- #33168
- Closed issues
- 1
- Attempts
- 3 PRs closed without merging
- Fix link checked
- 27 May 2026