Treat refresh_token_reused 400s as relogin-required (#24830)

## Summary
- classify known refresh-token terminal failures from `/oauth/token` as
permanent even when the backend returns `400`
- preserve the existing relogin-required message for
`refresh_token_reused` instead of retrying and collapsing into a generic
cloud requirements error
- add regression coverage for `400 refresh_token_reused`

## Testing
- `just fmt`
- `cargo test -p codex-login`
This commit is contained in:
alexsong-oai
2026-05-27 18:37:02 -07:00
committed by GitHub
parent eb1cc3824c
commit 6111791d0b
2 changed files with 70 additions and 3 deletions

View File

@@ -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"
);
}

View File

@@ -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<()> {