fix(openai): clarify OAuth connection failures and extend timeouts

Distinguish connection failures from uncertain authorization results and retain redacted diagnostic causes. Increase token connection timeout to 30 seconds and total request timeout to 60 seconds.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-11-05 21:50:08 +08:00
co-authored by Codex
parent 1d88c9a4a5
commit c73e7126a2
7 changed files with 243 additions and 35 deletions
@@ -1867,12 +1867,15 @@ fn log_import_failure(stage: &'static str, error: &'static str) {
}
fn map_oauth_error(error: CodexOAuthAdminError) -> ProviderAdminError {
use crate::credential::token_client::AuthorizationCodeExchangeError as Exchange;
use CodexOAuthAdminError as Error;
use ProviderAdminErrorKind as Kind;
let (kind, message) = match error {
let (kind, message) = match &error {
Error::InvalidInput => (Kind::Invalid, "OpenAI 授权参数不合法,请重新发起授权"),
Error::CallbackRejected => (Kind::Invalid, "OpenAI 授权回调校验失败,请重新发起授权"),
Error::TokenRejected => (Kind::Invalid, "OpenAI 拒绝了授权码,请重新发起授权"),
Error::TokenRejected | Error::Exchange(Exchange::Rejected) => {
(Kind::Invalid, "OpenAI 拒绝了授权码,请重新发起授权")
}
Error::Credential => (Kind::Invalid, "OpenAI 授权返回的凭据不完整或无效"),
Error::NotFound | Error::FlowExpired => (
Kind::NotFound,
@@ -1882,29 +1885,51 @@ fn map_oauth_error(error: CodexOAuthAdminError) -> ProviderAdminError {
Kind::Conflict,
"OpenAI 授权流程正在处理或已被更新,请先检查授权状态",
),
Error::Ambiguous => (
Error::Exchange(Exchange::Ambiguous { .. }) => (
Kind::Ambiguous,
"OpenAI 授权结果未知,请先核对账号状态,不要立即重复提交",
),
Error::UpstreamUnavailable => (
Error::Exchange(Exchange::Connection {
timed_out: true, ..
}) => (
Kind::Unavailable,
"连接 OpenAI 授权服务超时,请检查出站连接",
),
Error::Exchange(Exchange::Connection {
timed_out: false, ..
}) => (
Kind::Unavailable,
"无法连接 OpenAI 授权服务,请检查出站连接",
),
Error::Exchange(Exchange::Unavailable) => (
Kind::Unavailable,
"OpenAI 授权服务暂不可用,请检查出站连接后重试",
),
Error::StorageUnavailable => (Kind::Unavailable, "OpenAI 授权状态存储暂不可用,请稍后重试"),
};
provider_admin_error(kind).with_public_message(message)
provider_admin_error(kind)
.with_public_message(message)
.with_source(error)
}
const fn oauth_error_code(error: &CodexOAuthAdminError) -> &'static str {
use crate::credential::token_client::AuthorizationCodeExchangeError as Exchange;
match error {
CodexOAuthAdminError::InvalidInput => "invalid_input",
CodexOAuthAdminError::NotFound => "not_found",
CodexOAuthAdminError::Conflict => "conflict",
CodexOAuthAdminError::FlowExpired => "flow_expired",
CodexOAuthAdminError::CallbackRejected => "callback_rejected",
CodexOAuthAdminError::TokenRejected => "token_rejected",
CodexOAuthAdminError::UpstreamUnavailable => "upstream_unavailable",
CodexOAuthAdminError::Ambiguous => "ambiguous",
CodexOAuthAdminError::TokenRejected
| CodexOAuthAdminError::Exchange(Exchange::Rejected) => "token_rejected",
CodexOAuthAdminError::Exchange(Exchange::Unavailable) => "upstream_unavailable",
CodexOAuthAdminError::Exchange(Exchange::Connection {
timed_out: true, ..
}) => "connect_timeout",
CodexOAuthAdminError::Exchange(Exchange::Connection {
timed_out: false, ..
}) => "connect_failed",
CodexOAuthAdminError::Exchange(Exchange::Ambiguous { .. }) => "ambiguous",
CodexOAuthAdminError::StorageUnavailable => "storage_unavailable",
CodexOAuthAdminError::Credential => "credential",
}
@@ -318,7 +318,7 @@ pub enum CodexOAuthPendingClaimOutcome {
InProgress,
}
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
#[derive(Debug, thiserror::Error)]
pub enum CodexOAuthAdminError {
#[error("invalid Codex OAuth admin input")]
InvalidInput,
@@ -332,10 +332,8 @@ pub enum CodexOAuthAdminError {
CallbackRejected,
#[error("Codex OAuth token exchange was rejected")]
TokenRejected,
#[error("Codex OAuth upstream is unavailable")]
UpstreamUnavailable,
#[error("Codex OAuth exchange send state is ambiguous")]
Ambiguous,
#[error("Codex OAuth token exchange failed")]
Exchange(#[source] AuthorizationCodeExchangeError),
#[error("Codex OAuth pending storage is unavailable")]
StorageUnavailable,
#[error("Codex OAuth account mutation failed")]
@@ -702,7 +700,7 @@ impl CodexOAuthAdminService {
proxy,
)
.await
.map_err(map_exchange_error)?;
.map_err(CodexOAuthAdminError::Exchange)?;
let mut secret = tokens.secret;
if secret.refresh_token.is_none() {
secret.refresh_token = fallback_refresh_token;
@@ -905,14 +903,6 @@ fn map_authorization_settlement_error(error: CodexOAuthPendingStoreError) -> Adm
}
}
fn map_exchange_error(error: AuthorizationCodeExchangeError) -> CodexOAuthAdminError {
match error {
AuthorizationCodeExchangeError::Rejected => CodexOAuthAdminError::TokenRejected,
AuthorizationCodeExchangeError::Unavailable => CodexOAuthAdminError::UpstreamUnavailable,
AuthorizationCodeExchangeError::Ambiguous => CodexOAuthAdminError::Ambiguous,
}
}
fn map_admin_error(_: CodexCredentialAdminError) -> CodexOAuthAdminError {
CodexOAuthAdminError::Credential
}
@@ -15,8 +15,9 @@ use std::fmt;
use std::time::Duration;
const MAX_OAUTH_RESPONSE_BYTES: usize = 64 * 1024;
const TOKEN_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const TOKEN_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const TOKEN_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
// 总期限为连接后的响应留出预算,避免先于连接期限触发而丢失连接失败分类
const TOKEN_REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
/// Codex Desktop 使用的官方 OAuth public client
pub const OFFICIAL_CODEX_OAUTH_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
@@ -332,15 +333,24 @@ impl fmt::Debug for AuthorizationTokenSet {
}
}
/// Authorization Code exchange 的低基数失败
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
/// Authorization Code exchange 的失败分类及受控原因链
#[derive(Debug, Clone, thiserror::Error)]
pub enum AuthorizationCodeExchangeError {
#[error("authorization code was rejected")]
Rejected,
#[error("authorization code exchange is unavailable")]
Unavailable,
#[error("authorization code connection failed (request URL redacted)")]
Connection {
timed_out: bool,
#[source]
source: gateway_core::error::ErrorSource,
},
#[error("authorization code exchange send state is ambiguous")]
Ambiguous,
Ambiguous {
#[source]
source: gateway_core::error::ErrorSource,
},
}
#[async_trait]
@@ -646,15 +656,23 @@ impl AuthorizationCodeExchanger for OpenAiTokenClient {
.send()
.await
.map_err(|error| {
if is_safe_to_retry_refresh_transport(&error) {
AuthorizationCodeExchangeError::Unavailable
// 连接阶段包含代理隧道和 TLS 握手,此时授权码尚未发往上游
if error.is_connect() {
AuthorizationCodeExchangeError::Connection {
timed_out: error.is_timeout(),
source: error.without_url().into(),
}
} else {
AuthorizationCodeExchangeError::Ambiguous
AuthorizationCodeExchangeError::Ambiguous {
source: error.without_url().into(),
}
}
})?;
let (status, body) = read_bounded_response(response)
.await
.map_err(|_| AuthorizationCodeExchangeError::Ambiguous)?;
let (status, body) = read_bounded_response(response).await.map_err(|error| {
AuthorizationCodeExchangeError::Ambiguous {
source: error.into(),
}
})?;
if !status.is_success() {
return Err(match status.as_u16() {
429 | 500..=599 => AuthorizationCodeExchangeError::Unavailable,
@@ -833,6 +833,113 @@ async fn openai_admin_provider_persists_the_full_pending_envelope_and_binds_owne
assert_eq!(pending.values.lock().expect("OAuth pending").len(), 1);
}
#[tokio::test]
async fn oauth_connection_failures_preserve_safe_diagnostics_and_release_the_claim() {
use std::error::Error as _;
use tokio::io::AsyncReadExt as _;
for timed_out in [false, true] {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let proxy_task = if timed_out {
Some(tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = [0; 4096];
let count = socket.read(&mut request).await.unwrap();
assert!(request[..count].starts_with(b"CONNECT "));
std::future::pending::<()>().await;
}))
} else {
drop(listener);
None
};
let pending = Arc::new(TestOAuthPending::default());
let mut config = valid_config();
config.config.auth.oauth_token_endpoint =
"https://oauth.example.invalid/oauth/token?PRIVATE_URL_MARKER".to_owned();
let bundle = provider_openai::initialize(
config.config.clone(),
provider_ports_with(Arc::new(MemoryAccountStore::default()), pending.clone()),
Arc::new(gateway_core::account::UpstreamInfoReader::default()),
)
.await
.unwrap();
let context = MutationContext {
actor: MutationActor::System,
request_id: "oauth-connection-failure".to_owned(),
};
let started = bundle
.admin_provider()
.start_authorization(
PendingAuthorizationMutation::new(
ProviderKind::new("openai").unwrap(),
AuthorizationMutationTarget::Create {
name: String::new(),
},
AuthorizationOwnerBinding::from_context(&context),
)
.with_outbound_proxy(Some(
gateway_core::account::OutboundProxy::parse(&format!(
"http://proxy-user:PRIVATE_PROXY_PASSWORD@{address}"
))
.unwrap(),
)),
)
.await
.unwrap();
let state = {
let values = pending.values.lock().unwrap();
let (_, payload, _, _) = values.values().next().unwrap();
payload.expose_to_provider()["state"]
.as_str()
.unwrap()
.to_owned()
};
let error = bundle
.admin_provider()
.complete_authorization(CompleteAuthorization {
settings: None,
context,
flow_id: started.flow_id,
callback_url: format!(
"http://127.0.0.1:1455/auth/callback?code=PRIVATE_CODE&state={state}"
),
})
.await
.unwrap_err();
if let Some(task) = proxy_task {
task.abort();
assert!(task.await.unwrap_err().is_cancelled());
}
assert_eq!(error.kind(), ProviderAdminErrorKind::Unavailable);
assert_eq!(
error.public_message(),
Some(if timed_out {
"连接 OpenAI 授权服务超时,请检查出站连接"
} else {
"无法连接 OpenAI 授权服务,请检查出站连接"
}),
);
let mut source = error.source();
let transport = loop {
let cause = source.expect("transport cause must reach the admin boundary");
if let Some(transport) = cause.downcast_ref::<reqwest::Error>() {
break transport;
}
source = cause.source();
};
assert!(transport.is_connect());
assert_eq!(transport.is_timeout(), timed_out);
assert!(transport.url().is_none());
assert!(!format!("{error:?} {error}").contains("PRIVATE_"));
let details =
gateway_core::error::ErrorDetails::capture(Some(&error.into()), None, true).unwrap();
assert!(!details.as_str().contains("PRIVATE_"));
let values = pending.values.lock().unwrap();
assert!(values.values().next().unwrap().3.is_none());
}
}
#[tokio::test]
async fn openai_reauthorization_pending_payload_reuses_the_account_installation_id() {
let accounts = Arc::new(MemoryAccountStore::default());
@@ -567,5 +567,5 @@ async fn first_exchange_permits_missing_identity_claims_but_requires_a_parseable
let error = complete("header.not-base64.signature".to_owned())
.await
.expect_err("official local payload parsing rejects malformed id tokens");
assert_eq!(error, CodexOAuthAdminError::TokenRejected);
assert!(matches!(error, CodexOAuthAdminError::TokenRejected));
}
@@ -365,6 +365,68 @@ async fn authorization_code_exchange_keeps_upstream_access_and_refresh_tokens_ve
);
}
#[tokio::test]
async fn authorization_code_response_timeouts_remain_ambiguous_without_exposing_request_material() {
use std::error::Error as _;
use std::time::Duration;
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
for partial_body in [false, true] {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = [0; 4096];
let count = socket.read(&mut request).await.unwrap();
assert!(request[..count].starts_with(b"POST "));
if partial_body {
socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n{\"access_token\":\"PRIVATE_BODY").await.unwrap();
}
std::future::pending::<()>().await;
});
let client = OpenAiTokenClient::new(
reqwest::Client::builder()
.no_proxy()
.timeout(Duration::from_millis(250))
.build()
.unwrap(),
TokenClientConfig {
client_id: "test-public-client".to_owned(),
token_endpoint: format!("http://{address}/oauth/token?PRIVATE_URL_MARKER"),
},
provider_openai::transport::profile::CodexWireProfileState::new(Default::default()),
);
let error = client
.exchange_authorization_code(AuthorizationCodeGrant {
code: SecretString::from("PRIVATE_CODE"),
code_verifier: SecretString::from("PRIVATE_VERIFIER"),
})
.await
.unwrap_err();
server.abort();
assert!(server.await.unwrap_err().is_cancelled());
assert!(matches!(
error,
AuthorizationCodeExchangeError::Ambiguous { .. }
));
let mut source = error.source();
let transport = loop {
let cause = source.expect("transport cause must survive classification");
if let Some(transport) = cause.downcast_ref::<reqwest::Error>() {
break transport;
}
source = cause.source();
};
assert!(transport.is_timeout());
assert!(!transport.is_connect());
assert!(transport.url().is_none());
assert!(!format!("{error:?} {error}").contains("PRIVATE_"));
let details =
gateway_core::error::ErrorDetails::capture(Some(&error.into()), None, true).unwrap();
assert!(!details.as_str().contains("PRIVATE_"));
}
}
#[tokio::test]
async fn authorization_code_exchange_requires_id_token_and_json_response() {
let missing_id = MockServer::start().await;
@@ -393,7 +455,7 @@ async fn authorization_code_exchange_requires_id_token_and_json_response() {
})
.await
.expect_err("invalid token response fails closed");
assert_eq!(error, AuthorizationCodeExchangeError::Rejected);
assert!(matches!(error, AuthorizationCodeExchangeError::Rejected));
}
}
+6
View File
@@ -1556,6 +1556,12 @@ OAuth start 使用:
账号提交成功后,`complete` 保留 24 小时的完成结果。同一管理员使用相同 `provider`、`flowId`
重试时返回原账号结果,不再执行授权或更新凭据;新提交的 `callbackUrl` 和 `settings` 不会再次应用
OpenAI 授权码交换与令牌刷新请求的连接超时为 30 秒,单次上游请求总超时为 60 秒
- 授权码交换未能建立连接时返回 `50301`,提示区分连接超时与其他连接失败
- 无法确认发送结果或未完整接收响应时返回 `50202`,应先核对账号状态,不立即重复提交授权码
- 代理出口探测成功不代表 OpenAI 授权端点可达
### OpenAI 身份、额度与状态
- OAuth 文件导入接受 camelCase 与 snake_case 的三个 token 字段,内部统一保存为