mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix(accounts): preserve upstream error messages
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user