fix(openai): align OAuth refresh error handling

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-08-17 12:30:05 +08:00
co-authored by Codex
parent 5ce36931a8
commit 2a9cb02cc9
10 changed files with 238 additions and 113 deletions
+6 -2
View File
@@ -49,7 +49,9 @@ use crate::credential::{
CompleteCodexOAuthAuthorization, CompletedCodexOAuthCredential, ExportManagedCodexCredential,
StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
};
use crate::credential::{CodexCredentialCodec, CodexOAuthSecret, oauth_owner_ref};
use crate::credential::{
CodexCredentialCodec, CodexOAuthSecret, oauth_owner_ref, parse_access_token_expiration,
};
use crate::transport::CodexWebSocketPool;
use crate::transport::profile::{
CodexDesktopReleaseSnapshot, CodexDesktopReleaseStatus, CodexWireProfile, CodexWireProfileState,
@@ -380,8 +382,10 @@ impl ProviderAdmin for OpenAiAdminProvider {
.oauth()
.and_then(|oauth| oauth.id_token.clone());
}
let access_token_expires_at =
parse_access_token_expiration(secret.access_token.expose_secret());
let prepared = CodexCredentialAdmin
.prepare_refreshed_oauth_rotation(current, secret, None, None)
.prepare_refreshed_oauth_rotation(current, secret, access_token_expires_at, None)
.map_err(map_credential_admin_error)?;
prepared_rotation(prepared, command.account.provider_kind)
}
@@ -819,7 +819,13 @@ impl CodexCredentialAdminService {
.await
.inspect_err(|error| log_manual_refresh_failure(&account_id, error))
.map_err(map_refresh_failure)?;
let access_token_rotated = tokens.access_token.is_some();
let access_token_expires_at = match tokens.access_token.as_deref() {
Some(access_token) => parse_access_token_expiration(access_token),
None => current
.account
.access_token_expires_at()
.map(DateTime::<Utc>::from),
};
let secret = CodexOAuthSecret {
access_token: tokens
.access_token
@@ -839,17 +845,6 @@ impl CodexCredentialAdminService {
Some(account_id.as_str()),
&secret,
);
let access_token_expires_at = if access_token_rotated {
tokens
.expires_in
.and_then(|expires_in| SystemTime::now().checked_add(expires_in))
.map(DateTime::<Utc>::from)
} else {
current
.account
.access_token_expires_at()
.map(DateTime::<Utc>::from)
};
// 正常预刷新由 worker 读取当时的 runtime policy 动态判断;这里仅清除
// 之前瞬态失败留下的 retry-not-before。
let next_refresh_at = None;
@@ -1016,6 +1011,7 @@ impl CodexCredentialAdminService {
let access_token = tokens
.access_token
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
let access_token_expires_at = parse_access_token_expiration(&access_token);
let secret = CodexOAuthSecret {
access_token: SecretString::from(access_token),
// RT 未轮换时仍保留导入提供的 RT,保证本次补全不会丢失后续刷新能力。
@@ -1030,10 +1026,6 @@ impl CodexCredentialAdminService {
Some(account_id.as_str()),
&secret,
);
let access_token_expires_at = tokens
.expires_in
.and_then(|expires_in| SystemTime::now().checked_add(expires_in))
.map(DateTime::<Utc>::from);
Ok((secret, access_token_expires_at))
}
@@ -1173,6 +1165,8 @@ fn log_manual_refresh_failure(account_id: &ProviderAccountId, error: &RefreshFai
failure_class = error.classification(),
upstream_message = ?error.message(),
upstream_status = ?upstream.map(super::token_client::RefreshUpstreamFailure::status),
upstream_code = ?upstream.and_then(super::token_client::RefreshUpstreamFailure::code),
upstream_type = ?upstream.and_then(super::token_client::RefreshUpstreamFailure::error_type),
upstream_body = ?upstream.map(super::token_client::RefreshUpstreamFailure::body),
"OpenAI OAuth manual refresh failed"
);
@@ -19,6 +19,7 @@ pub(crate) use affinity::{
derive_codex_cyber_policy_session_key, derive_codex_session_affinity_key,
};
pub(crate) use oauth::oauth_owner_ref;
pub(crate) use types::parse_access_token_expiration;
pub use agent_identity::{
CodexAgentIdentityError, CodexAgentIdentitySecret, CodexAgentIdentityTaskRegistrar,
@@ -1,10 +1,6 @@
//! Codex Authorization Code + PKCE/OIDC 管理流。
use std::{
fmt,
sync::Arc,
time::{Duration, SystemTime},
};
use std::{fmt, sync::Arc, time::Duration};
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
@@ -35,7 +31,7 @@ use super::token_client::{
AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant,
OFFICIAL_CODEX_OAUTH_CLIENT_ID, OFFICIAL_CODEX_REDIRECT_URI,
};
use super::types::parse_chatgpt_jwt_claims;
use super::types::{parse_access_token_expiration, parse_chatgpt_jwt_claims};
use crate::transport::profile::CodexWireProfileState;
const AUTHORIZATION_ENDPOINT: &str = "https://auth.openai.com/oauth/authorize";
@@ -527,13 +523,12 @@ impl CodexOAuthAdminService {
.map(current_oauth_refresh_token)
.transpose()?
.flatten();
let (mut secret, id_token, expires_in) = self
let (mut secret, id_token) = self
.exchange_pending(&pending, callback_url, fallback_refresh_token)
.await?;
let mutation = pending.mutation.clone();
let access_token_expires_at = expires_in
.and_then(|expires_in| SystemTime::now().checked_add(expires_in))
.map(DateTime::<Utc>::from);
let access_token_expires_at =
parse_access_token_expiration(secret.access_token.expose_secret());
let metadata = parse_chatgpt_jwt_claims(id_token.expose_secret())
.map_err(|_| CodexOAuthAdminError::TokenRejected)?;
secret.id_token = Some(id_token);
@@ -671,14 +666,7 @@ impl CodexOAuthAdminService {
pending: &CodexPendingAuthorization,
callback_url: &str,
fallback_refresh_token: Option<SecretString>,
) -> Result<
(
super::types::CodexOAuthSecret,
SecretString,
Option<Duration>,
),
CodexOAuthAdminError,
> {
) -> Result<(super::types::CodexOAuthSecret, SecretString), CodexOAuthAdminError> {
let (code, callback_state) = callback_parts(callback_url)?;
if !constant_time_equal(
pending.state.expose_secret().as_bytes(),
@@ -709,7 +697,7 @@ impl CodexOAuthAdminService {
.as_ref()
.map(ExposeSecret::expose_secret),
);
Ok((secret, tokens.id_token, tokens.expires_in))
Ok((secret, tokens.id_token))
}
}
@@ -21,7 +21,7 @@ use thiserror::Error;
use super::recovery_log::{CodexOAuthRecoveryOperation, record_oauth_recovery};
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
use super::token_client::{RefreshFailure, RefreshUpstreamFailure, TokenPair, TokenRefresher};
use super::types::CodexOAuthSecret;
use super::types::{CodexOAuthSecret, parse_access_token_expiration};
const PROVIDER_NAME: &str = "openai";
const MAX_REFRESH_BATCH: u32 = 1_000;
@@ -101,6 +101,8 @@ fn log_refresh_deferred(
reason,
upstream_message = ?upstream_message,
upstream_status = ?upstream.map(RefreshUpstreamFailure::status),
upstream_code = ?upstream.and_then(RefreshUpstreamFailure::code),
upstream_type = ?upstream.and_then(RefreshUpstreamFailure::error_type),
upstream_body = ?upstream.map(RefreshUpstreamFailure::body),
retry_at = %DateTime::<Utc>::from(retry_at),
access_token_expires_at = ?access_token_expires_at.map(DateTime::<Utc>::from),
@@ -437,9 +439,11 @@ impl CodexCredentialRefreshService {
access_token,
refresh_token: rotated_refresh_token,
id_token: rotated_id_token,
expires_in,
} = tokens;
let access_token_rotated = access_token.is_some();
let access_token_expires_at = match access_token.as_deref() {
Some(access_token) => parse_access_token_expiration(access_token).map(SystemTime::from),
None => due.account.access_token_expires_at(),
};
let CodexOAuthSecret {
access_token: current_access_token,
refresh_token: current_refresh_token,
@@ -466,12 +470,6 @@ impl CodexCredentialRefreshService {
.as_ref()
.map(ExposeSecret::expose_secret),
);
let observed_at = SystemTime::now();
let access_token_expires_at = if access_token_rotated {
expires_in.and_then(|value| observed_at.checked_add(value))
} else {
due.account.access_token_expires_at()
};
// 成功轮换清掉仅用于失败退避的 retry-not-before;正常预刷新窗口由
// worker 结合当前 runtime policy 动态判断,不写入账号时间字段。
let next_refresh_at = None;
@@ -553,6 +551,8 @@ impl CodexCredentialRefreshService {
reason,
upstream_message = ?message.as_deref(),
upstream_status = ?upstream.map(RefreshUpstreamFailure::status),
upstream_code = ?upstream.and_then(RefreshUpstreamFailure::code),
upstream_type = ?upstream.and_then(RefreshUpstreamFailure::error_type),
upstream_body = ?upstream.map(RefreshUpstreamFailure::body),
access_token_expires_at = ?account.access_token_expires_at()
.map(DateTime::<Utc>::from),
@@ -27,9 +27,6 @@ pub struct TokenPair {
pub refresh_token: Option<String>,
/// 官方刷新响应省略时由持久化调用方保留当前 ID token。
pub id_token: Option<String>,
/// OAuth endpoint 可以省略或返回零 expiry;这不影响已经成功的 token
/// exchange,只意味着调用方不能据此安排下一次刷新。
pub expires_in: Option<Duration>,
}
impl fmt::Debug for TokenPair {
@@ -45,7 +42,6 @@ impl fmt::Debug for TokenPair {
&self.refresh_token.as_ref().map(|_| "[REDACTED]"),
)
.field("id_token", &self.id_token.as_ref().map(|_| "[REDACTED]"))
.field("expires_in", &self.expires_in)
.finish()
}
}
@@ -121,13 +117,21 @@ impl RefreshFailure {
#[derive(Clone, PartialEq, Eq)]
pub struct RefreshUpstreamFailure {
status: u16,
code: Option<String>,
error_type: Option<String>,
body: String,
}
impl RefreshUpstreamFailure {
fn new(status: StatusCode, body: &[u8]) -> Self {
fn new(status: StatusCode, body: &[u8], error: Option<&RefreshErrorResponse>) -> Self {
Self {
status: status.as_u16(),
code: error
.and_then(RefreshErrorResponse::code)
.map(str::to_owned),
error_type: error
.and_then(RefreshErrorResponse::error_type)
.map(str::to_owned),
body: String::from_utf8_lossy(body).into_owned(),
}
}
@@ -137,6 +141,16 @@ impl RefreshUpstreamFailure {
self.status
}
#[must_use]
pub fn code(&self) -> Option<&str> {
self.code.as_deref()
}
#[must_use]
pub fn error_type(&self) -> Option<&str> {
self.error_type.as_deref()
}
#[must_use]
pub fn body(&self) -> &str {
&self.body
@@ -148,6 +162,8 @@ impl fmt::Debug for RefreshUpstreamFailure {
formatter
.debug_struct("RefreshUpstreamFailure")
.field("status", &self.status)
.field("code", &self.code)
.field("error_type", &self.error_type)
.field("body", &"<redacted>")
.finish()
}
@@ -182,7 +198,6 @@ impl fmt::Debug for AuthorizationCodeGrant {
pub struct AuthorizationTokenSet {
pub secret: crate::credential::CodexOAuthSecret,
pub id_token: SecretString,
pub expires_in: Option<Duration>,
}
impl fmt::Debug for AuthorizationTokenSet {
@@ -191,7 +206,6 @@ impl fmt::Debug for AuthorizationTokenSet {
.debug_struct("AuthorizationTokenSet")
.field("secret", &"[REDACTED]")
.field("id_token", &"[REDACTED]")
.field("expires_in", &self.expires_in)
.finish()
}
}
@@ -273,7 +287,6 @@ struct RefreshTokenResponse {
access_token: Option<String>,
refresh_token: Option<String>,
id_token: Option<String>,
expires_in: Option<u64>,
}
#[derive(Serialize)]
@@ -300,6 +313,8 @@ enum RefreshError {
struct RefreshErrorDetails {
code: Option<String>,
message: Option<String>,
#[serde(rename = "type")]
error_type: Option<String>,
}
impl RefreshErrorResponse {
@@ -319,6 +334,13 @@ impl RefreshErrorResponse {
}
.map(ToOwned::to_owned)
}
fn error_type(&self) -> Option<&str> {
match self.error.as_ref() {
Some(RefreshError::Details(error)) => error.error_type.as_deref(),
Some(RefreshError::Code(_)) | None => None,
}
}
}
#[derive(Deserialize)]
@@ -326,7 +348,6 @@ struct AuthorizationCodeResponse {
access_token: String,
refresh_token: String,
id_token: String,
expires_in: Option<u64>,
}
#[async_trait]
@@ -398,7 +419,6 @@ impl AuthorizationCodeExchanger for OpenAiTokenClient {
id_token: None,
},
id_token,
expires_in: optional_expiry(tokens.expires_in),
})
}
}
@@ -447,36 +467,37 @@ fn parse_token_pair(body: &[u8]) -> Result<TokenPair, ()> {
refresh_token: tokens.refresh_token,
// 官方刷新响应允许省略 ID token;缺失时由调用方保留当前值。
id_token: tokens.id_token,
expires_in: optional_expiry(tokens.expires_in),
})
}
fn optional_expiry(value: Option<u64>) -> Option<Duration> {
value
.filter(|seconds| *seconds > 0)
.map(Duration::from_secs)
}
fn classify_refresh_failure(status: StatusCode, body: &[u8]) -> RefreshFailure {
// 官方刷新错误的消息与错误码分别位于 `error.message`、`error.code`;
// `error` 字符串与顶层 `code` 仅用于兼容官方客户端自身的错误码提取契约。
let error = serde_json::from_slice::<RefreshErrorResponse>(body).ok();
let message = Some(refresh_failure_message(error.as_ref(), body));
let upstream = || Some(Box::new(RefreshUpstreamFailure::new(status, body)));
// 5xx 与 429 按状态码判为瞬态:其正文(CDN/网关页等)不是权威 OAuth 错误响应,
// 不据此终态失效账号,但官方 `error.message` 仍用于本次诊断。
if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS {
return RefreshFailure::Transport {
let upstream = || {
Some(Box::new(RefreshUpstreamFailure::new(
status,
body,
error.as_ref(),
)))
};
let normalized_code = error
.as_ref()
.and_then(RefreshErrorResponse::code)
.map(str::to_ascii_lowercase);
// 与官方 Codex 相同:三个明确的 RT 原因在任意 HTTP 状态下都是永久失败;
// 此外任意 401 永久失败,其他未知 OAuth code 保持暂态。
if matches!(
normalized_code.as_deref(),
Some("refresh_token_expired" | "refresh_token_reused" | "refresh_token_invalidated")
) || status == StatusCode::UNAUTHORIZED
{
return RefreshFailure::InvalidGrant {
message,
upstream: upstream(),
};
}
let Some(error) = error else {
return RefreshFailure::Transport {
message,
upstream: upstream(),
};
};
if message.as_deref().is_some_and(|message| {
message
.to_ascii_lowercase()
@@ -487,23 +508,6 @@ fn classify_refresh_failure(status: StatusCode, body: &[u8]) -> RefreshFailure {
upstream: upstream(),
};
}
let normalized_code = error.code().map(str::to_ascii_lowercase);
if matches!(
normalized_code.as_deref(),
Some(
"invalid_grant"
| "invalid_token"
| "access_denied"
| "refresh_token_expired"
| "refresh_token_reused"
| "refresh_token_invalidated"
)
) {
return RefreshFailure::InvalidGrant {
message,
upstream: upstream(),
};
}
RefreshFailure::Transport {
message,
upstream: upstream(),
@@ -3,6 +3,7 @@ use std::num::NonZeroU32;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, TimeZone as _, Utc};
use futures::future::BoxFuture;
use gateway_admin::model::accounts::AccountRecord;
@@ -762,6 +763,61 @@ async fn openai_admin_provider_rejects_unprepared_mutations_before_store_commit(
assert_eq!(refresh_error.kind(), ProviderAdminErrorKind::NotFound);
}
#[tokio::test]
async fn openai_rotation_preserves_the_new_access_token_jwt_expiration() {
let store = Arc::new(MemoryAccountStore::default());
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: "acct_admin_rotation_expiration".to_owned(),
name: "admin rotation expiration".to_owned(),
secret: secret("admin-rotation-access"),
verified_account: profile("chatgpt-admin-rotation-expiration"),
next_refresh_at: None,
enabled: true,
})
.await;
let account = store
.account("acct_admin_rotation_expiration")
.expect("stored account");
let record = account_record(&account);
let config = valid_config();
let bundle = provider_openai::initialize(
config.config.clone(),
provider_ports_with(store, Arc::new(TestOAuthPending::default())),
)
.await
.expect("OpenAI bundle");
let expires_at = Utc
.timestamp_opt(2_000_000_000, 0)
.single()
.expect("valid test timestamp");
let payload = URL_SAFE_NO_PAD.encode(
serde_json::to_vec(&serde_json::json!({"exp": expires_at.timestamp()}))
.expect("test JWT payload"),
);
let mut material = Map::new();
material.insert(
"access_token".to_owned(),
Value::String(format!("unverified-header.{payload}.unverified-signature")),
);
material.insert(
"refresh_token".to_owned(),
Value::String("admin-rotation-refresh".to_owned()),
);
let prepared = bundle
.admin_provider()
.prepare_rotation(PrepareCredentialRotation {
account: record,
provider_material: ProviderDocument::new(OpaqueProviderData::new(material)),
})
.await
.expect("JWT rotation should be prepared");
assert_eq!(prepared.facts().access_token_expires_at, Some(expires_at));
}
fn provider_ports() -> ProviderStorePorts {
provider_ports_with(
Arc::new(MemoryAccountStore::default()),
@@ -112,7 +112,6 @@ impl AuthorizationCodeExchanger for Exchanger {
id_token: None,
},
id_token: SecretString::from(self.id_token.clone()),
expires_in: Some(Duration::from_secs(60 * 60)),
})
}
}
@@ -4,7 +4,8 @@ use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, TimeZone as _, Utc};
use futures::future::BoxFuture;
use gateway_core::engine::credential::{ProviderAccountId, ProviderAccountStore};
use gateway_core::policy::ClientApiKeyId;
@@ -36,7 +37,6 @@ impl SingleUseRefresher {
access_token: Some("refreshed-access-token".to_owned()),
refresh_token: None,
id_token: None,
expires_in: Some(Duration::from_secs(60 * 60)),
})),
})
}
@@ -52,7 +52,17 @@ impl SingleUseRefresher {
access_token: Some("refreshed-access-token".to_owned()),
refresh_token: None,
id_token: Some(id_token.to_owned()),
expires_in: Some(Duration::from_secs(60 * 60)),
})),
})
}
fn with_access_token(access_token: String) -> Arc<Self> {
Arc::new(Self {
calls: AtomicUsize::new(0),
response: Mutex::new(Some(TokenPair {
access_token: Some(access_token),
refresh_token: None,
id_token: None,
})),
})
}
@@ -64,7 +74,6 @@ impl SingleUseRefresher {
access_token: None,
refresh_token: None,
id_token: None,
expires_in: Some(Duration::from_secs(60 * 60)),
})),
})
}
@@ -361,6 +370,48 @@ async fn scheduled_refresh_persists_a_returned_id_token() {
);
}
#[tokio::test]
async fn scheduled_refresh_uses_the_rotated_access_token_jwt_expiration() {
let expires_at = Utc
.timestamp_opt(2_000_000_000, 0)
.single()
.expect("valid test timestamp");
let payload = URL_SAFE_NO_PAD.encode(
serde_json::to_vec(&serde_json::json!({"exp": expires_at.timestamp()}))
.expect("test JWT payload"),
);
let refresher = SingleUseRefresher::with_access_token(format!(
"unverified-header.{payload}.unverified-signature"
));
let store = Arc::new(MemoryAccountStore::default());
let service = refresh_service(
&store,
refresher,
MutableRuntimePolicy::new(Duration::from_secs(5 * 60)),
);
let account_id = "acct_rotated_access_expiration";
seed_refreshable_account(
&store,
account_id,
SystemTime::now()
.checked_add(Duration::from_secs(120))
.expect("test expiry"),
None,
)
.await;
service.refresh_due().await.expect("refresh cycle");
assert_eq!(
store
.account(account_id)
.expect("refreshed account")
.access_token_expires_at()
.map(DateTime::<Utc>::from),
Some(expires_at)
);
}
#[tokio::test]
async fn scheduled_refresh_preserves_the_stored_token_set_when_rotation_fields_are_omitted() {
let store = Arc::new(MemoryAccountStore::default());
@@ -1,5 +1,3 @@
use std::time::Duration;
use provider_openai::credential::token_client::{
AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant,
OpenAiTokenClient, RefreshFailure, TokenClientConfig, TokenRefresher,
@@ -55,7 +53,7 @@ async fn oversized_chunked_oauth_response_should_fail_closed_and_redact_body() {
}
#[tokio::test]
async fn bounded_oauth_response_should_parse_lifetime_and_rotated_token() {
async fn bounded_oauth_response_should_parse_the_official_rotated_token_fields() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
@@ -77,7 +75,6 @@ async fn bounded_oauth_response_should_parse_lifetime_and_rotated_token() {
assert_eq!(tokens.access_token.as_deref(), Some("access-rotated"));
assert_eq!(tokens.refresh_token.as_deref(), Some("refresh-rotated"));
assert_eq!(tokens.id_token.as_deref(), Some("header.e30.signature"));
assert_eq!(tokens.expires_in, Some(Duration::from_secs(3600)));
}
#[tokio::test]
@@ -122,7 +119,6 @@ async fn refresh_response_should_allow_all_rotated_tokens_to_be_omitted() {
assert!(tokens.access_token.is_none());
assert!(tokens.refresh_token.is_none());
assert!(tokens.id_token.is_none());
assert!(tokens.expires_in.is_none());
}
#[tokio::test]
@@ -305,23 +301,50 @@ async fn refresh_token_reuse_should_be_classified_as_invalid_grant() {
assert_eq!(message.as_deref(), Some(upstream_message));
let upstream = upstream.expect("complete upstream failure");
assert_eq!(upstream.status(), 400);
assert_eq!(upstream.code(), Some("refresh_token_reused"));
assert_eq!(upstream.error_type(), Some("invalid_request_error"));
assert!(upstream.body().contains(r#""code":"refresh_token_reused""#));
}
#[tokio::test]
async fn oauth_error_without_nested_message_should_use_the_complete_body() {
async fn generic_invalid_grant_should_remain_transient_like_official_codex() {
let body = r#"{"error":"invalid_grant","error_description":"not the official refresh error message field"}"#;
let failure = refresh_failure(400, body).await;
let diagnostic = format!("{failure:?} {failure}");
let RefreshFailure::InvalidGrant { message, upstream } = failure else {
panic!("invalid_grant must be terminal");
let RefreshFailure::Transport { message, upstream } = failure else {
panic!("unknown official refresh code must remain transient");
};
assert_eq!(message.as_deref(), Some(body));
assert_eq!(upstream.expect("complete upstream failure").body(), body);
let upstream = upstream.expect("complete upstream failure");
assert_eq!(upstream.code(), Some("invalid_grant"));
assert_eq!(upstream.body(), body);
assert!(!diagnostic.contains("error_description"));
}
#[tokio::test]
async fn unauthorized_without_a_known_refresh_code_should_be_permanent() {
let body = r#"{
"error": {
"message": "Invalid refresh token.",
"type": "invalid_request_error",
"param": null,
"code": "invalid_refresh_token"
}
}"#;
let failure = refresh_failure(401, body).await;
let RefreshFailure::InvalidGrant { message, upstream } = failure else {
panic!("official Codex treats every 401 as permanent");
};
assert_eq!(message.as_deref(), Some("Invalid refresh token."));
let upstream = upstream.expect("complete upstream failure");
assert_eq!(upstream.status(), 401);
assert_eq!(upstream.code(), Some("invalid_refresh_token"));
assert_eq!(upstream.error_type(), Some("invalid_request_error"));
assert_eq!(upstream.body(), body);
}
#[tokio::test]
async fn deactivated_account_should_be_classified_as_banned() {
let failure = refresh_failure(
@@ -366,24 +389,29 @@ async fn token_revoked_text_without_invalid_grant_should_remain_temporary() {
}
#[tokio::test]
async fn server_error_or_rate_limit_must_stay_transient_even_with_oauth_error_body() {
// 5xx/429(含 CDN/网关页恰好嵌入 invalid_grant 字样)按状态码判瞬态,
// 不因正文子串把账号永久终态——正文只在 4xx 时才是权威 OAuth 错误。
async fn official_refresh_code_should_be_permanent_regardless_of_http_status() {
for status in [500, 502, 503, 429] {
let failure = refresh_failure(
status,
r#"{"error":{"code":"refresh_token_expired","message":"Refresh token expired."}}"#,
)
.await;
assert_transport_failure(
&failure,
status,
"Refresh token expired.",
r#"{"error":{"code":"refresh_token_expired","message":"Refresh token expired."}}"#,
assert!(
matches!(failure, RefreshFailure::InvalidGrant { .. }),
"official refresh code must take precedence for status {status}"
);
}
}
#[tokio::test]
async fn unknown_server_error_or_rate_limit_should_remain_transient() {
let body = r#"{"error":{"code":"temporarily_unavailable","message":"Try again."}}"#;
for status in [500, 502, 503, 429] {
let failure = refresh_failure(status, body).await;
assert_transport_failure(&failure, status, "Try again.", body);
}
}
fn assert_transport_failure(failure: &RefreshFailure, status: u16, message: &str, body: &str) {
let RefreshFailure::Transport {
message: actual_message,