diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index d94c48165d..9969318158 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -844,8 +844,8 @@ async fn request_chatgpt_token_refresh( } else { let body = response.text().await.unwrap_or_default(); tracing::error!("Failed to refresh token: {status}: {body}"); - if status == StatusCode::UNAUTHORIZED { - let failed = classify_refresh_token_failure(&body); + let failed = classify_refresh_token_failure(&body); + if status == StatusCode::UNAUTHORIZED || failed.reason != RefreshTokenFailedReason::Other { Err(RefreshTokenError::Permanent(failed)) } else { let message = try_parse_error_message(&body); @@ -871,7 +871,7 @@ fn classify_refresh_token_failure(body: &str) -> RefreshTokenFailedError { tracing::warn!( backend_code = normalized_code.as_deref(), backend_body = body, - "Encountered unknown 401 response while refreshing token" + "Encountered unknown response while refreshing token" ); } diff --git a/codex-rs/login/tests/suite/auth_refresh.rs b/codex-rs/login/tests/suite/auth_refresh.rs index afdf466d09..0b1c3c9eb2 100644 --- a/codex-rs/login/tests/suite/auth_refresh.rs +++ b/codex-rs/login/tests/suite/auth_refresh.rs @@ -625,6 +625,73 @@ async fn refresh_token_does_not_retry_after_permanent_failure() -> Result<()> { Ok(()) } +#[serial_test::serial(auth_refresh)] +#[tokio::test] +async fn refresh_token_does_not_retry_after_bad_request_reused_failure() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({ + "error": { + "code": "refresh_token_reused" + } + }))) + .expect(1) + .mount(&server) + .await; + + let ctx = RefreshTokenTestContext::new(&server).await?; + let initial_last_refresh = Utc::now() - Duration::days(1); + let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN); + let initial_auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(initial_tokens.clone()), + last_refresh: Some(initial_last_refresh), + agent_identity: None, + }; + ctx.write_auth(&initial_auth).await?; + + let first_err = ctx + .auth_manager + .refresh_token() + .await + .err() + .context("first refresh should fail")?; + assert_eq!( + first_err.failed_reason(), + Some(RefreshTokenFailedReason::Exhausted) + ); + + let second_err = ctx + .auth_manager + .refresh_token() + .await + .err() + .context("second refresh should fail without retrying")?; + assert_eq!( + second_err.failed_reason(), + Some(RefreshTokenFailedReason::Exhausted) + ); + + let stored = ctx.load_auth()?; + assert_eq!(stored, initial_auth); + let cached_auth = ctx + .auth_manager + .auth() + .await + .context("auth should remain cached")?; + let cached = cached_auth + .get_token_data() + .context("token data should remain cached")?; + assert_eq!(cached, initial_tokens); + + server.verify().await; + Ok(()) +} + #[serial_test::serial(auth_refresh)] #[tokio::test] async fn refresh_token_reloads_changed_auth_after_permanent_failure() -> Result<()> {