fix(accounts): preserve upstream error messages

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-08-16 13:28:28 +08:00
co-authored by Codex
parent ef37ae8d9e
commit 304242957a
8 changed files with 166 additions and 52 deletions
@@ -565,11 +565,7 @@ impl AccountsService for DefaultAccountsService {
let upstream_body = error
.upstream_response()
.map(|response| String::from_utf8_lossy(response.body()).into_owned());
let message = upstream_body
.as_deref()
.filter(|body| !body.is_empty())
.unwrap_or_else(|| error.client_message())
.to_owned();
let message = error.client_message().to_owned();
vec![AccountConnectionTestEvent::Failed {
message,
provider_error_code: error.client_error_code().map(ToOwned::to_owned),
@@ -1119,7 +1119,8 @@ pub struct AccountStateChange {
pub observed_at: SystemTime,
/// 受控错误原因;恢复 Ready 时必须清空。
pub error_reason: Option<AccountErrorReason>,
/// 已脱敏的上游诊断信息;恢复 Ready 时必须清空。
/// 供管理端展示的错误消息;结构化上游失败应保留原始 message,不能写入整个正文。
/// 恢复 Ready 时必须清空。
pub message: Option<String>,
}
@@ -1133,10 +1133,10 @@ fn china_rfc3339(value: DateTime<Utc>) -> String {
.to_rfc3339()
}
const fn map_refresh_failure(error: RefreshFailure) -> CodexCredentialAdminError {
fn map_refresh_failure(error: RefreshFailure) -> CodexCredentialAdminError {
match error {
RefreshFailure::InvalidGrant => CodexCredentialAdminError::RefreshRejected,
RefreshFailure::Banned => CodexCredentialAdminError::AccountBanned,
RefreshFailure::InvalidGrant { .. } => CodexCredentialAdminError::RefreshRejected,
RefreshFailure::Banned { .. } => CodexCredentialAdminError::AccountBanned,
RefreshFailure::RetryableTransport => CodexCredentialAdminError::RefreshUnavailable,
RefreshFailure::Transport => CodexCredentialAdminError::RefreshAmbiguous,
}
@@ -258,6 +258,7 @@ impl CodexCredentialRefreshService {
CredentialState::Expired,
AccountErrorReason::CredentialExpired,
"refresh_recovery_window_exhausted",
None,
CodexCredentialRefreshOutcome::Invalidated { account_id },
)
.await;
@@ -293,22 +294,24 @@ impl CodexCredentialRefreshService {
.ok_or(CodexCredentialRefreshError::InvalidRefreshResponse)?;
match self.refresher.refresh(refresh_token.expose_secret()).await {
Ok(tokens) => self.persist_success(due, tokens).await,
Err(RefreshFailure::InvalidGrant) => {
Err(RefreshFailure::InvalidGrant { message }) => {
self.persist_terminal(
&due.account,
CredentialState::Expired,
AccountErrorReason::CredentialExpired,
"refresh_invalid_grant",
message,
CodexCredentialRefreshOutcome::Invalidated { account_id },
)
.await
}
Err(RefreshFailure::Banned) => {
Err(RefreshFailure::Banned { message }) => {
self.persist_terminal(
&due.account,
CredentialState::Banned,
AccountErrorReason::AccountBanned,
"account_banned",
message,
CodexCredentialRefreshOutcome::Banned { account_id },
)
.await
@@ -484,6 +487,7 @@ impl CodexCredentialRefreshService {
credential_state: CredentialState,
error_reason: AccountErrorReason,
reason: &'static str,
message: Option<String>,
outcome: CodexCredentialRefreshOutcome,
) -> Result<CodexCredentialRefreshOutcome, CodexCredentialRefreshError> {
match self.repository.load_runtime_credential(account).await {
@@ -502,7 +506,7 @@ impl CodexCredentialRefreshService {
credential_state,
SystemTime::now(),
Some(error_reason),
Some(reason.to_owned()),
Some(message.unwrap_or_else(|| reason.to_owned())),
)
.await
{
@@ -51,12 +51,12 @@ impl fmt::Debug for TokenPair {
}
/// Codex token 刷新的稳定失败分类。
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum RefreshFailure {
#[error("refresh token is invalid or expired")]
InvalidGrant,
InvalidGrant { message: Option<String> },
#[error("account is banned")]
Banned,
Banned { message: Option<String> },
#[error("refresh transport failed before server processing")]
RetryableTransport,
#[error("refresh transport failed after possible server processing")]
@@ -186,6 +186,45 @@ struct RefreshTokenResponse {
expires_in: Option<u64>,
}
#[derive(Deserialize)]
struct RefreshErrorResponse {
error: Option<RefreshError>,
code: Option<String>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum RefreshError {
Details(RefreshErrorDetails),
Code(String),
}
#[derive(Deserialize)]
struct RefreshErrorDetails {
code: Option<String>,
message: Option<String>,
}
impl RefreshErrorResponse {
fn code(&self) -> Option<&str> {
match self.error.as_ref() {
Some(RefreshError::Details(error)) => error.code.as_deref(),
Some(RefreshError::Code(code)) => Some(code.as_str()),
None => None,
}
.or(self.code.as_deref())
}
fn message(&self) -> Option<String> {
match self.error.as_ref() {
Some(RefreshError::Details(error)) => error.message.as_deref(),
Some(RefreshError::Code(_)) | None => None,
}
.filter(|message| !message.trim().is_empty())
.map(ToOwned::to_owned)
}
}
#[derive(Deserialize)]
struct AuthorizationCodeResponse {
access_token: String,
@@ -317,18 +356,32 @@ fn classify_refresh_failure(status: StatusCode, body: &[u8]) -> RefreshFailure {
if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS {
return RefreshFailure::Transport;
}
// 4xx 正文才是权威 OAuth 错误响应,据其区分真过期/封禁与其余瞬态。
let lower = String::from_utf8_lossy(body).to_ascii_lowercase();
if lower.contains("account has been deactivated") {
return RefreshFailure::Banned;
// 官方刷新错误的消息与错误码分别位于 `error.message`、`error.code`;
// `error` 字符串与顶层 `code` 仅用于兼容官方客户端自身的错误码提取契约。
let Ok(error) = serde_json::from_slice::<RefreshErrorResponse>(body) else {
return RefreshFailure::Transport;
};
let message = error.message();
if message.as_deref().is_some_and(|message| {
message
.to_ascii_lowercase()
.contains("account has been deactivated")
}) {
return RefreshFailure::Banned { message };
}
if lower.contains("invalid_grant")
|| lower.contains("invalid_token")
|| lower.contains("access_denied")
|| lower.contains("refresh_token_expired")
|| lower.contains("refresh_token_reused")
{
return RefreshFailure::InvalidGrant;
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 };
}
RefreshFailure::Transport
}
@@ -82,6 +82,17 @@ impl TokenRefresher for SingleUseRefresher {
}
}
struct FailingRefresher {
failure: RefreshFailure,
}
#[async_trait]
impl TokenRefresher for FailingRefresher {
async fn refresh(&self, _: &str) -> Result<TokenPair, RefreshFailure> {
Err(self.failure.clone())
}
}
struct RefreshLeases;
impl ProviderLeasePort for RefreshLeases {
@@ -267,6 +278,50 @@ async fn scheduled_refresh_uses_the_current_margin_without_persisting_a_normal_s
);
}
#[tokio::test]
async fn scheduled_refresh_persists_the_original_upstream_error_message() {
let store = Arc::new(MemoryAccountStore::default());
let policy = MutableRuntimePolicy::new(Duration::from_secs(5 * 60));
let upstream_message = "Refresh token has already been used.";
let service = CodexCredentialRefreshService::new(
store.repository(),
Arc::new(FailingRefresher {
failure: RefreshFailure::InvalidGrant {
message: Some(upstream_message.to_owned()),
},
}),
Arc::new(RefreshLeases),
Arc::new(RefreshCredentialState),
policy,
);
let account_id = "acct_invalid_refresh_token";
seed_refreshable_account(
&store,
account_id,
SystemTime::now()
.checked_add(Duration::from_secs(120))
.expect("test expiry"),
None,
)
.await;
let outcomes = service.refresh_due().await.expect("refresh cycle");
assert!(matches!(
outcomes.as_slice(),
[CodexCredentialRefreshOutcome::Invalidated {
account_id: invalidated_account_id,
}] if invalidated_account_id == account_id
));
assert_eq!(
store
.account(account_id)
.expect("invalidated account")
.last_error_message(),
Some(upstream_message)
);
}
#[tokio::test]
async fn scheduled_refresh_persists_a_returned_id_token() {
let store = Arc::new(MemoryAccountStore::default());
@@ -273,24 +273,46 @@ async fn authorization_code_exchange_requires_id_token_and_json_response() {
#[tokio::test]
async fn refresh_token_reuse_should_be_classified_as_invalid_grant() {
let upstream_message = "Your refresh token has already been used to generate a new access token. Please try signing in again.";
let failure = refresh_failure(
400,
r#"{"error":"invalid_grant","error_description":"refresh_token_reused"}"#,
r#"{"error":{"message":"Your refresh token has already been used to generate a new access token. Please try signing in again.","type":"invalid_request_error","param":null,"code":"refresh_token_reused"}}"#,
)
.await;
assert_eq!(failure, RefreshFailure::InvalidGrant);
assert_eq!(
failure,
RefreshFailure::InvalidGrant {
message: Some(upstream_message.to_owned())
}
);
}
#[tokio::test]
async fn oauth_error_description_should_not_be_used_as_the_upstream_message() {
let failure = refresh_failure(
400,
r#"{"error":"invalid_grant","error_description":"not the official refresh error message field"}"#,
)
.await;
assert_eq!(failure, RefreshFailure::InvalidGrant { message: None });
}
#[tokio::test]
async fn deactivated_account_should_be_classified_as_banned() {
let failure = refresh_failure(
403,
r#"{"error":"access_denied","error_description":"account has been deactivated"}"#,
r#"{"error":{"code":"access_denied","message":"account has been deactivated"}}"#,
)
.await;
assert_eq!(failure, RefreshFailure::Banned);
assert_eq!(
failure,
RefreshFailure::Banned {
message: Some("account has been deactivated".to_owned())
}
);
}
#[tokio::test]
@@ -328,7 +350,7 @@ async fn server_error_or_rate_limit_must_stay_transient_even_with_oauth_error_bo
for status in [500, 502, 503, 429] {
let failure = refresh_failure(
status,
r#"{"error":"invalid_grant","error_description":"refresh_token_expired"}"#,
r#"{"error":{"code":"refresh_token_expired","message":"Refresh token expired."}}"#,
)
.await;
assert_eq!(
@@ -42,26 +42,13 @@ interface ConnectionTestEvent {
model?: string
payload?: ConnectionTestRequestPayload
success?: boolean
upstreamBody?: string
upstreamStatus?: number
error?: string
}
function connectionTestErrorText(event: ConnectionTestEvent) {
if (typeof event.upstreamBody === 'string' && event.upstreamBody.length > 0)
return event.upstreamBody
if (Number.isInteger(event.upstreamStatus))
return `HTTP ${event.upstreamStatus}`
return event.error || '测试连接失败'
}
function connectionTestFailureLabel(event: ConnectionTestEvent) {
const hasUpstreamResponse
= (typeof event.upstreamBody === 'string' && event.upstreamBody.length > 0)
|| Number.isInteger(event.upstreamStatus)
return hasUpstreamResponse
? '上游返回错误'
: '未能向上游发起请求'
if (typeof event.error === 'string' && event.error.trim().length > 0)
return event.error
return '测试连接失败'
}
export function useAccountConnectionTest(options: { reload: () => Promise<unknown> }) {
@@ -284,11 +271,7 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
}
if (event.type === 'error') {
connectionTestError.value = connectionTestErrorText(event)
appendConnectionTestLog(
connectionTestFailureLabel(event),
'danger',
connectionTestError.value,
)
appendConnectionTestLog(connectionTestError.value, 'danger')
finishConnectionTest('error')
clearConnectionTestRun()
void options.reload()