diff --git a/hermes_cli/auth_nous.py b/hermes_cli/auth_nous.py index a20e250ae2..f2fdb7a6a0 100644 --- a/hermes_cli/auth_nous.py +++ b/hermes_cli/auth_nous.py @@ -616,12 +616,16 @@ def _refresh_access_token( from hermes_cli.auth import _OAUTH_GRANT_DEAD_CODES try: error_payload = response.json() - except Exception as exc: - raise _nous_err("Refresh token exchange failed") from exc + except Exception: + error_payload = {} # Only an explicit OAuth grant-dead code is terminal: a 429/404 gateway body without an # ``error`` key says nothing about the refresh token, so it must not wipe credentials. - code = error_payload.get("error") - code = str(code) if code is not None else None + # A 401/403 from the token endpoint is the exception: it always means the refresh token + # itself was rejected (same rule as the Codex sibling), so keep the base grant-dead default. + raw_code = error_payload.get("error") + if raw_code is None and response.status_code in {401, 403}: + raw_code = "invalid_grant" + code = None if raw_code is None else str(raw_code) description = str(error_payload.get("error_description") or "Refresh token exchange failed") relogin = code in _OAUTH_GRANT_DEAD_CODES # OAuth 2.1 "refresh token reuse": an external process (health check, monitoring tool, custom diff --git a/tests/hermes_cli/test_auth_nous_provider.py b/tests/hermes_cli/test_auth_nous_provider.py index d453108156..a3c59e81dd 100644 --- a/tests/hermes_cli/test_auth_nous_provider.py +++ b/tests/hermes_cli/test_auth_nous_provider.py @@ -685,21 +685,24 @@ def test_refresh_token_reuse_detection_surfaces_actionable_message(): @pytest.mark.parametrize( - "status_code, body, expected_code, expected_retryable", + "status_code, body, expected_code, expected_terminal", [ - (500, None, "temporarily_unavailable", True), - (503, None, "temporarily_unavailable", True), - (599, None, "temporarily_unavailable", True), - (429, {"code": "429", "message": "rate limited"}, None, None), - (404, {"message": "not found"}, None, None), - (400, ValueError("not json"), None, None), + (500, None, "temporarily_unavailable", False), + (503, None, "temporarily_unavailable", False), + (599, None, "temporarily_unavailable", False), + (429, {"code": "429", "message": "rate limited"}, None, False), + (404, {"message": "not found"}, None, False), + (400, ValueError("not json"), None, False), + (401, {"message": "unauthorized"}, "invalid_grant", True), + (403, ValueError("not json"), "invalid_grant", True), ], ) def test_refresh_token_exchange_without_grant_error_code_is_not_terminal( - status_code, body, expected_code, expected_retryable + status_code, body, expected_code, expected_terminal ): """A Portal 5xx is transient even when its body is not OAuth JSON (#120976), and a - non-5xx body that carries no OAuth ``error`` code must not be treated as a dead grant.""" + non-5xx body that carries no OAuth ``error`` code must not be treated as a dead grant -- + except a 401/403, which always means the refresh token itself was rejected.""" from hermes_cli.auth import _is_terminal_nous_refresh_error, _refresh_access_token class _FakeResponse: @@ -726,9 +729,10 @@ def test_refresh_token_exchange_without_grant_error_code_is_not_terminal( ) assert exc_info.value.code == expected_code - assert exc_info.value.relogin_required is False - assert exc_info.value.retryable is expected_retryable - assert _is_terminal_nous_refresh_error(exc_info.value) is False + assert exc_info.value.relogin_required is expected_terminal + assert _is_terminal_nous_refresh_error(exc_info.value) is expected_terminal + if expected_code == "temporarily_unavailable": + assert exc_info.value.retryable is True def test_runtime_refresh_503_preserves_nous_oauth_credentials(tmp_path, monkeypatch):