mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix(admin): preserve actionable provider error messages
Retain safe provider messages across the admin HTTP boundary while keeping internal diagnostics private. Distinguish refresh contention, known upstream rejection, and unknown outcomes without changing worker retry or terminal-state policies. Cover OpenAI refresh-token failure reasons, including token_expired, and improve adjacent OpenAI/xAI admin guidance. Add provider and HTTP regressions and synchronize API and architecture contracts. Validated 1400 related backend tests, 12 architecture checks, strict Clippy, Rustfmt, gateway build, and an isolated real-upstream invalid-token browser flow. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -331,7 +331,7 @@ impl AdminError {
|
||||
pub(crate) fn map_admin_service_error(error: gateway_admin::model::AdminError) -> AdminError {
|
||||
use gateway_admin::model::AdminErrorKind;
|
||||
|
||||
match error.kind() {
|
||||
let mut response = match error.kind() {
|
||||
AdminErrorKind::Invalid => AdminError::bad_request(error.message()),
|
||||
AdminErrorKind::Unauthorized => AdminError::admin_session_required(),
|
||||
AdminErrorKind::NotFound => AdminError::not_found(error.message()),
|
||||
@@ -341,7 +341,19 @@ pub(crate) fn map_admin_service_error(error: gateway_admin::model::AdminError) -
|
||||
AdminErrorKind::UpstreamResultUnknown => AdminError::upstream_result_unknown(),
|
||||
AdminErrorKind::Unavailable => AdminError::service_unavailable(),
|
||||
AdminErrorKind::Internal => AdminError::internal(),
|
||||
};
|
||||
// Admin owner 已负责公开文案,不能在 HTTP 边界再次丢掉安全的业务原因。
|
||||
// 认证与未知内部异常仍使用固定提示,不放行底层诊断或任意 500 消息。
|
||||
if matches!(
|
||||
error.kind(),
|
||||
AdminErrorKind::BadGateway
|
||||
| AdminErrorKind::UpstreamResultUnknown
|
||||
| AdminErrorKind::Unavailable
|
||||
) && !error.message().trim().is_empty()
|
||||
{
|
||||
response.body.message = error.message().to_owned();
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
impl IntoResponse for AdminError {
|
||||
|
||||
@@ -318,3 +318,187 @@ async fn admin_auth_failures_should_use_stable_chinese_contracts() {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
mod provider {
|
||||
use std::time::SystemTime;
|
||||
|
||||
use chrono::Utc;
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
Revision,
|
||||
accounts::{AccountPageItem, AccountRecord},
|
||||
},
|
||||
ports::provider::{ProviderAdminError, ProviderAdminErrorKind as Kind},
|
||||
};
|
||||
use gateway_core::{
|
||||
account::{
|
||||
AccountStatusFacts, AccountWeight, CredentialState, QuotaState, resolve_account_status,
|
||||
},
|
||||
routing::ProviderKind,
|
||||
};
|
||||
use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_public_errors_survive_import_and_refresh_http_handlers() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
for provider in ["openai", "xai"] {
|
||||
*fixture.account.lock().unwrap() = Some(account(provider));
|
||||
for (kind, status, code, message) in [
|
||||
(Kind::Invalid, 400, 40001, "刷新令牌已被使用,请重新授权"),
|
||||
(
|
||||
Kind::Conflict,
|
||||
409,
|
||||
40901,
|
||||
"令牌刷新繁忙,请等待当前刷新完成后重试",
|
||||
),
|
||||
(
|
||||
Kind::BadGateway,
|
||||
502,
|
||||
50201,
|
||||
"OpenAI 返回的 Codex PAT 身份资料不完整或格式无效,请稍后重试",
|
||||
),
|
||||
(
|
||||
Kind::Unavailable,
|
||||
503,
|
||||
50301,
|
||||
"暂时无法向 OpenAI 验证 Codex PAT,请稍后重试",
|
||||
),
|
||||
(
|
||||
Kind::Ambiguous,
|
||||
502,
|
||||
50202,
|
||||
"令牌刷新结果未知,请先核对账号状态,不要立即重复刷新",
|
||||
),
|
||||
] {
|
||||
*fixture.provider_error.lock().unwrap() = Some(
|
||||
ProviderAdminError::new(kind)
|
||||
.with_public_message(message)
|
||||
.with_message("raw-upstream-secret-marker"),
|
||||
);
|
||||
for (path, body) in [
|
||||
(
|
||||
"/api/admin/accounts/refresh",
|
||||
json!({"accountId": "acct_error_test"}),
|
||||
),
|
||||
(
|
||||
"/api/admin/accounts/import",
|
||||
json!({"provider": provider, "data": {"accessToken": "at-synthetic-test"}}),
|
||||
),
|
||||
] {
|
||||
let response = send(&fixture, path, body).await;
|
||||
assert_eq!(response.headers()["x-request-id"], "req_admin_errors");
|
||||
let actual = response_json(response).await;
|
||||
assert_eq!(
|
||||
actual,
|
||||
(
|
||||
StatusCode::from_u16(status).unwrap(),
|
||||
"application/json".to_owned(),
|
||||
json!({"code": code, "message": message, "data": null}),
|
||||
)
|
||||
);
|
||||
assert!(!actual.2.to_string().contains("raw-upstream-secret-marker"));
|
||||
assert_eq!(fixture.auth.audit_count(), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_diagnostics_without_public_messages_still_use_safe_fallbacks() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
for (kind, status, code, message) in [
|
||||
(Kind::BadGateway, 502, 50201, "上游服务请求失败"),
|
||||
(Kind::Unavailable, 503, 50301, "Provider 服务暂不可用"),
|
||||
(
|
||||
Kind::Ambiguous,
|
||||
502,
|
||||
50202,
|
||||
"上游执行结果未知,请刷新状态后再决定是否重试",
|
||||
),
|
||||
(Kind::Internal, 500, 50001, "服务内部错误"),
|
||||
] {
|
||||
let error = ProviderAdminError::new(kind).with_message("private-provider-diagnostics");
|
||||
// 即使 Provider 错标了公开文案,未知内部异常仍不得通过 500 信封下发。
|
||||
*fixture.provider_error.lock().unwrap() = Some(if kind == Kind::Internal {
|
||||
error.with_public_message("internal-detail-must-stay-hidden")
|
||||
} else {
|
||||
error
|
||||
});
|
||||
let response = send(
|
||||
&fixture,
|
||||
"/api/admin/accounts/import",
|
||||
json!({"provider": "openai", "data": {}}),
|
||||
)
|
||||
.await;
|
||||
let (actual_status, _, body) = response_json(response).await;
|
||||
assert_eq!(actual_status.as_u16(), status);
|
||||
assert_eq!(
|
||||
body,
|
||||
json!({"code": code, "message": message, "data": null})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn send(fixture: &AdminTestFixture, path: &str, body: Value) -> axum::response::Response {
|
||||
let mut request = request(Method::POST, path, Body::from(body.to_string()));
|
||||
request
|
||||
.headers_mut()
|
||||
.insert(header::COOKIE, SESSION_COOKIE.parse().unwrap());
|
||||
request
|
||||
.headers_mut()
|
||||
.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());
|
||||
let name = header::HeaderName::from_static("x-request-id");
|
||||
app(fixture.state())
|
||||
.layer(PropagateRequestIdLayer::new(name.clone()))
|
||||
.layer(SetRequestIdLayer::new(name, MakeRequestUuid))
|
||||
.oneshot(request)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn account(provider: &str) -> AccountPageItem {
|
||||
let now = Utc::now();
|
||||
let facts = AccountStatusFacts {
|
||||
enabled: true,
|
||||
credential_state: CredentialState::Ready,
|
||||
access_token_expires_at: None,
|
||||
quota: QuotaState::unknown(),
|
||||
rate_limited_until: None,
|
||||
last_error_reason: None,
|
||||
last_error_message: None,
|
||||
};
|
||||
AccountPageItem {
|
||||
account: AccountRecord {
|
||||
id: "acct_error_test".to_owned(),
|
||||
provider_kind: ProviderKind::new(provider).unwrap(),
|
||||
groups: Vec::new(),
|
||||
name: "synthetic account".to_owned(),
|
||||
email: None,
|
||||
upstream_user_id: None,
|
||||
upstream_account_id: None,
|
||||
plan_type: None,
|
||||
authentication_kind: "oauth".to_owned(),
|
||||
credential_revision: Revision::new(1).unwrap(),
|
||||
has_refresh_token: true,
|
||||
access_token_expires_at: None,
|
||||
next_refresh_at: None,
|
||||
enabled: facts.enabled,
|
||||
concurrency_limit: None,
|
||||
weight: AccountWeight::default(),
|
||||
outbound_proxy: None,
|
||||
credential_state: facts.credential_state,
|
||||
credential_observed_at: now,
|
||||
quota: facts.quota,
|
||||
last_error_reason: None,
|
||||
last_error_message: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
projection: resolve_account_status(&facts, SystemTime::now()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,8 @@ pub(super) struct AdminTestFixture {
|
||||
pub ops_errors: Arc<Mutex<Vec<OpsError>>>,
|
||||
pub dashboard_observation: Arc<Mutex<Option<DashboardObservation>>>,
|
||||
pub dashboard_summary_range: Arc<Mutex<Option<TimeRange>>>,
|
||||
pub provider_error: Arc<Mutex<Option<ProviderAdminError>>>,
|
||||
pub account: Arc<Mutex<Option<AccountPageItem>>>,
|
||||
}
|
||||
|
||||
impl AdminTestFixture {
|
||||
@@ -115,6 +117,8 @@ impl AdminTestFixture {
|
||||
let ops_errors = Arc::new(Mutex::new(Vec::new()));
|
||||
let dashboard_observation = Arc::new(Mutex::new(None));
|
||||
let dashboard_summary_range = Arc::new(Mutex::new(None));
|
||||
let provider_error = Arc::new(Mutex::new(None));
|
||||
let account = Arc::new(Mutex::new(None));
|
||||
let unused = Arc::new(UnusedStore {
|
||||
usage_records: Arc::clone(&usage_records),
|
||||
usage_detail: Arc::clone(&usage_detail),
|
||||
@@ -122,6 +126,7 @@ impl AdminTestFixture {
|
||||
ops_errors: Arc::clone(&ops_errors),
|
||||
dashboard_observation: Arc::clone(&dashboard_observation),
|
||||
dashboard_summary_range: Arc::clone(&dashboard_summary_range),
|
||||
account: Arc::clone(&account),
|
||||
});
|
||||
let stores = AdminStorePorts::new(
|
||||
AdminAccountStorePorts::new(
|
||||
@@ -137,8 +142,8 @@ impl AdminTestFixture {
|
||||
gateway_admin::ports::backup::BackupStorePorts::disabled(),
|
||||
);
|
||||
let providers: Vec<Arc<dyn ProviderAdmin>> = vec![
|
||||
Arc::new(UnusedProvider::new("openai")),
|
||||
Arc::new(UnusedProvider::new("xai")),
|
||||
Arc::new(UnusedProvider::new("openai", Arc::clone(&provider_error))),
|
||||
Arc::new(UnusedProvider::new("xai", Arc::clone(&provider_error))),
|
||||
];
|
||||
let bundle = gateway_admin::initialize(
|
||||
AdminConfig {
|
||||
@@ -165,6 +170,8 @@ impl AdminTestFixture {
|
||||
ops_errors,
|
||||
dashboard_observation,
|
||||
dashboard_summary_range,
|
||||
provider_error,
|
||||
account,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -702,6 +709,7 @@ struct UnusedStore {
|
||||
ops_errors: Arc<Mutex<Vec<OpsError>>>,
|
||||
dashboard_observation: Arc<Mutex<Option<DashboardObservation>>>,
|
||||
dashboard_summary_range: Arc<Mutex<Option<TimeRange>>>,
|
||||
account: Arc<Mutex<Option<AccountPageItem>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -716,9 +724,12 @@ impl AccountStore for UnusedStore {
|
||||
|
||||
async fn load_account(
|
||||
&self,
|
||||
_: &str,
|
||||
id: &str,
|
||||
_: AccountRuntimeSnapshot,
|
||||
) -> AdminStoreResult<Option<AccountPageItem>> {
|
||||
if let Some(account) = self.account.lock().expect("account").as_ref() {
|
||||
return Ok((account.account.id == id).then(|| account.clone()));
|
||||
}
|
||||
Err(unavailable("account"))
|
||||
}
|
||||
|
||||
@@ -927,12 +938,14 @@ impl ObservabilityStore for UnusedStore {
|
||||
|
||||
struct UnusedProvider {
|
||||
kind: ProviderKind,
|
||||
error: Arc<Mutex<Option<ProviderAdminError>>>,
|
||||
}
|
||||
|
||||
impl UnusedProvider {
|
||||
fn new(kind: &str) -> Self {
|
||||
fn new(kind: &str, error: Arc<Mutex<Option<ProviderAdminError>>>) -> Self {
|
||||
Self {
|
||||
kind: ProviderKind::new(kind).expect("provider kind"),
|
||||
error,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -973,7 +986,12 @@ impl ProviderAdmin for UnusedProvider {
|
||||
&self,
|
||||
_: PrepareCredentialImport,
|
||||
) -> Result<PreparedCredentialImport, ProviderAdminError> {
|
||||
Err(unsupported_provider())
|
||||
Err(self
|
||||
.error
|
||||
.lock()
|
||||
.expect("provider error")
|
||||
.clone()
|
||||
.unwrap_or_else(unsupported_provider))
|
||||
}
|
||||
|
||||
async fn start_authorization(
|
||||
@@ -1001,7 +1019,12 @@ impl ProviderAdmin for UnusedProvider {
|
||||
&self,
|
||||
_: PrepareCredentialRefresh,
|
||||
) -> Result<PreparedCredentialRotation, ProviderAdminError> {
|
||||
Err(unsupported_provider())
|
||||
Err(self
|
||||
.error
|
||||
.lock()
|
||||
.expect("provider error")
|
||||
.clone()
|
||||
.unwrap_or_else(unsupported_provider))
|
||||
}
|
||||
|
||||
async fn quota(
|
||||
|
||||
@@ -408,7 +408,8 @@ impl ProviderAdmin for OpenAiAdminProvider {
|
||||
.await
|
||||
.map_err(map_store_error)?;
|
||||
if !account_matches_record(¤t.account, &command.account) {
|
||||
return Err(provider_admin_error(ProviderAdminErrorKind::Conflict));
|
||||
return Err(provider_admin_error(ProviderAdminErrorKind::Conflict)
|
||||
.with_public_message("账号凭据已被更新,请刷新账号列表后重试"));
|
||||
}
|
||||
let mut secret = rotation_secret(command.provider_material)?;
|
||||
if secret.id_token.is_none() {
|
||||
@@ -440,7 +441,8 @@ impl ProviderAdmin for OpenAiAdminProvider {
|
||||
.await
|
||||
.map_err(map_store_error)?;
|
||||
if !account_matches_record(¤t.account, &command.account) {
|
||||
return Err(provider_admin_error(ProviderAdminErrorKind::Conflict));
|
||||
return Err(provider_admin_error(ProviderAdminErrorKind::Conflict)
|
||||
.with_public_message("账号凭据已被更新,请刷新账号列表后重试"));
|
||||
}
|
||||
let prepared = self
|
||||
.credentials
|
||||
@@ -1313,52 +1315,80 @@ fn map_store_error(error: gateway_core::error::StoreError) -> ProviderAdminError
|
||||
fn map_credential_admin_error(error: CodexCredentialAdminError) -> ProviderAdminError {
|
||||
use crate::credential::token_client::PersonalAccessTokenError;
|
||||
use CodexCredentialAdminError as Error;
|
||||
use ProviderAdminErrorKind as Kind;
|
||||
let upstream_message = error.upstream_message().map(ToOwned::to_owned);
|
||||
let public_message = match &error {
|
||||
Error::PersonalAccessToken(error) => Some(match error {
|
||||
PersonalAccessTokenError::InvalidToken => {
|
||||
"Codex PAT 格式无效:应为 at- 开头的完整令牌,不能包含空白或控制字符"
|
||||
}
|
||||
PersonalAccessTokenError::Rejected => {
|
||||
"OpenAI 拒绝了 Codex PAT:令牌可能无效、已过期、已撤销或没有访问权限"
|
||||
}
|
||||
PersonalAccessTokenError::Unavailable => "暂时无法向 OpenAI 验证 Codex PAT,请稍后重试",
|
||||
PersonalAccessTokenError::InvalidResponse => {
|
||||
"OpenAI 返回的 Codex PAT 身份资料不完整或格式无效,请稍后重试"
|
||||
}
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
let kind = match error {
|
||||
Error::PersonalAccessToken(
|
||||
PersonalAccessTokenError::InvalidToken | PersonalAccessTokenError::Rejected,
|
||||
)
|
||||
| Error::InvalidInput
|
||||
| Error::InvalidCredential
|
||||
| Error::MissingRefreshToken
|
||||
| Error::RefreshRejected { .. }
|
||||
| Error::AccountBanned { .. } => ProviderAdminErrorKind::Invalid,
|
||||
Error::NotFound => ProviderAdminErrorKind::NotFound,
|
||||
Error::RefreshLeaseUnavailable | Error::RefreshAmbiguous { .. } => {
|
||||
ProviderAdminErrorKind::Conflict
|
||||
let (kind, public_message) = match error {
|
||||
Error::PersonalAccessToken(error) => match error {
|
||||
PersonalAccessTokenError::InvalidToken => (
|
||||
Kind::Invalid,
|
||||
"Codex PAT 格式无效:应为 at- 开头的完整令牌,不能包含空白或控制字符",
|
||||
),
|
||||
PersonalAccessTokenError::Rejected => (
|
||||
Kind::Invalid,
|
||||
"OpenAI 拒绝了 Codex PAT:令牌可能无效、已过期、已撤销或没有访问权限",
|
||||
),
|
||||
PersonalAccessTokenError::Unavailable => (
|
||||
Kind::Unavailable,
|
||||
"暂时无法向 OpenAI 验证 Codex PAT,请稍后重试",
|
||||
),
|
||||
PersonalAccessTokenError::InvalidResponse => (
|
||||
Kind::BadGateway,
|
||||
"OpenAI 返回的 Codex PAT 身份资料不完整或格式无效,请稍后重试",
|
||||
),
|
||||
},
|
||||
Error::InvalidInput => (Kind::Invalid, "OpenAI 账号输入格式不合法,请检查导入内容"),
|
||||
Error::InvalidCredential => (
|
||||
Kind::Invalid,
|
||||
"OpenAI 凭据不完整或格式无效,请检查凭据或重新授权",
|
||||
),
|
||||
Error::MissingRefreshToken => (Kind::Invalid, "账号没有刷新令牌,请重新授权"),
|
||||
Error::RefreshRejected { code, .. } => (
|
||||
Kind::Invalid,
|
||||
refresh_rejection_message(code.as_deref()).unwrap_or("刷新令牌已失效,请重新授权"),
|
||||
),
|
||||
Error::AccountBanned { .. } => (Kind::Invalid, "OpenAI 账号已被停用,请检查账号状态"),
|
||||
Error::NotFound => (Kind::NotFound, "OpenAI 账号不存在,请刷新账号列表"),
|
||||
Error::RefreshLeaseUnavailable => {
|
||||
(Kind::Conflict, "令牌刷新繁忙,请等待当前刷新完成后重试")
|
||||
}
|
||||
Error::PersonalAccessToken(PersonalAccessTokenError::InvalidResponse) => {
|
||||
ProviderAdminErrorKind::BadGateway
|
||||
}
|
||||
Error::PersonalAccessToken(PersonalAccessTokenError::Unavailable)
|
||||
| Error::RefreshUnavailable => ProviderAdminErrorKind::Unavailable,
|
||||
};
|
||||
let error = provider_admin_error(kind);
|
||||
let error = match public_message {
|
||||
Some(message) => error.with_public_message(message),
|
||||
None => error,
|
||||
Error::RefreshUnavailable => (
|
||||
Kind::Unavailable,
|
||||
"令牌刷新服务暂不可用,请检查出站连接与依赖服务",
|
||||
),
|
||||
Error::RefreshUpstream { status, code, .. } => (
|
||||
Kind::BadGateway,
|
||||
refresh_rejection_message(code.as_deref()).unwrap_or(match status {
|
||||
401 | 403 => "OpenAI 拒绝了令牌刷新,请检查账号授权状态",
|
||||
429 => "OpenAI 令牌刷新请求被限流,请稍后重试",
|
||||
500..=599 => "OpenAI 令牌刷新服务异常,请稍后重试",
|
||||
_ => "OpenAI 未能完成令牌刷新,请检查账号授权与上游服务状态",
|
||||
}),
|
||||
),
|
||||
Error::RefreshAmbiguous { .. } => (
|
||||
Kind::Ambiguous,
|
||||
"令牌刷新结果未知,请先核对账号状态,不要立即重复刷新",
|
||||
),
|
||||
};
|
||||
let error = provider_admin_error(kind).with_public_message(public_message);
|
||||
match upstream_message {
|
||||
Some(message) => error.with_message(message),
|
||||
None => error,
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_rejection_message(code: Option<&str>) -> Option<&'static str> {
|
||||
// 与官方 Codex 的失败原因对齐;只解释管理提示,不改动 Worker 的终态/退避判定。
|
||||
match code.map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("refresh_token_expired") => Some("刷新令牌已过期,请重新授权"),
|
||||
Some("refresh_token_reused") => Some("刷新令牌已被使用,请重新授权"),
|
||||
Some("refresh_token_invalidated") => Some("刷新令牌已被撤销,请重新授权"),
|
||||
// token_expired 也用于 RT 校验失败,不据此断言具体到期原因。
|
||||
Some("token_expired") => Some("刷新令牌不可用,请重新授权"),
|
||||
Some("invalid_grant") => Some("刷新令牌无效或已失效,请重新授权"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
const fn credential_admin_error_code(error: &CodexCredentialAdminError) -> &'static str {
|
||||
match error {
|
||||
CodexCredentialAdminError::PersonalAccessToken(_) => {
|
||||
@@ -1372,6 +1402,7 @@ const fn credential_admin_error_code(error: &CodexCredentialAdminError) -> &'sta
|
||||
CodexCredentialAdminError::RefreshRejected { .. } => "refresh_rejected",
|
||||
CodexCredentialAdminError::AccountBanned { .. } => "account_banned",
|
||||
CodexCredentialAdminError::RefreshUnavailable => "refresh_unavailable",
|
||||
CodexCredentialAdminError::RefreshUpstream { .. } => "refresh_upstream_failed",
|
||||
CodexCredentialAdminError::RefreshAmbiguous { .. } => "refresh_ambiguous",
|
||||
}
|
||||
}
|
||||
@@ -1400,17 +1431,31 @@ fn log_import_failure(stage: &'static str, error: &'static str) {
|
||||
|
||||
fn map_oauth_error(error: CodexOAuthAdminError) -> ProviderAdminError {
|
||||
use CodexOAuthAdminError as Error;
|
||||
provider_admin_error(match error {
|
||||
Error::InvalidInput
|
||||
| Error::CallbackRejected
|
||||
| Error::TokenRejected
|
||||
| Error::Credential => ProviderAdminErrorKind::Invalid,
|
||||
Error::NotFound | Error::FlowExpired => ProviderAdminErrorKind::NotFound,
|
||||
Error::Conflict | Error::Ambiguous => ProviderAdminErrorKind::Conflict,
|
||||
Error::UpstreamUnavailable | Error::StorageUnavailable => {
|
||||
ProviderAdminErrorKind::Unavailable
|
||||
}
|
||||
})
|
||||
use ProviderAdminErrorKind as Kind;
|
||||
let (kind, message) = match error {
|
||||
Error::InvalidInput => (Kind::Invalid, "OpenAI 授权参数不合法,请重新发起授权"),
|
||||
Error::CallbackRejected => (Kind::Invalid, "OpenAI 授权回调校验失败,请重新发起授权"),
|
||||
Error::TokenRejected => (Kind::Invalid, "OpenAI 拒绝了授权码,请重新发起授权"),
|
||||
Error::Credential => (Kind::Invalid, "OpenAI 授权返回的凭据不完整或无效"),
|
||||
Error::NotFound | Error::FlowExpired => (
|
||||
Kind::NotFound,
|
||||
"OpenAI 授权流程不存在或已过期,请重新发起授权",
|
||||
),
|
||||
Error::Conflict => (
|
||||
Kind::Conflict,
|
||||
"OpenAI 授权流程正在处理或已被更新,请先检查授权状态",
|
||||
),
|
||||
Error::Ambiguous => (
|
||||
Kind::Ambiguous,
|
||||
"OpenAI 授权结果未知,请先核对账号状态,不要立即重复提交",
|
||||
),
|
||||
Error::UpstreamUnavailable => (
|
||||
Kind::Unavailable,
|
||||
"OpenAI 授权服务暂不可用,请检查出站连接后重试",
|
||||
),
|
||||
Error::StorageUnavailable => (Kind::Unavailable, "OpenAI 授权状态存储暂不可用,请稍后重试"),
|
||||
};
|
||||
provider_admin_error(kind).with_public_message(message)
|
||||
}
|
||||
|
||||
const fn oauth_error_code(error: &CodexOAuthAdminError) -> &'static str {
|
||||
@@ -1430,15 +1475,24 @@ const fn oauth_error_code(error: &CodexOAuthAdminError) -> &'static str {
|
||||
|
||||
fn map_quota_error(error: CodexCredentialQuotaError) -> ProviderAdminError {
|
||||
use CodexCredentialQuotaError as Error;
|
||||
provider_admin_error(match error {
|
||||
Error::InvalidCredentialData => ProviderAdminErrorKind::Invalid,
|
||||
Error::NotFound => ProviderAdminErrorKind::NotFound,
|
||||
Error::RevisionConflict => ProviderAdminErrorKind::Conflict,
|
||||
Error::CredentialRefreshRequired
|
||||
| Error::Repository(_)
|
||||
| Error::Store { .. }
|
||||
| Error::Upstream { .. } => ProviderAdminErrorKind::Unavailable,
|
||||
})
|
||||
use ProviderAdminErrorKind as Kind;
|
||||
let (kind, message) = match error {
|
||||
Error::InvalidCredentialData => (Kind::Invalid, "OpenAI 额度查询凭据无效,请检查账号授权"),
|
||||
Error::NotFound => (Kind::NotFound, "OpenAI 额度查询账号不存在"),
|
||||
Error::RevisionConflict => (Kind::Conflict, "账号凭据已被更新,请刷新账号列表后重试"),
|
||||
Error::CredentialRefreshRequired => (
|
||||
Kind::Unavailable,
|
||||
"OpenAI 额度查询需要有效凭据,请刷新令牌后重试",
|
||||
),
|
||||
Error::Repository(_) | Error::Store { .. } => {
|
||||
(Kind::Unavailable, "OpenAI 额度查询的依赖服务暂不可用")
|
||||
}
|
||||
Error::Upstream { .. } => (
|
||||
Kind::Unavailable,
|
||||
"OpenAI 额度查询失败,请检查出站连接与上游服务",
|
||||
),
|
||||
};
|
||||
provider_admin_error(kind).with_public_message(message)
|
||||
}
|
||||
|
||||
fn map_profile_statistics_error(error: CodexProfileStatisticsError) -> ProviderAdminError {
|
||||
@@ -1451,7 +1505,8 @@ fn map_profile_statistics_error(error: CodexProfileStatisticsError) -> ProviderA
|
||||
provider_admin_error(ProviderAdminErrorKind::Unavailable)
|
||||
}
|
||||
Error::CredentialRefreshRequired { upstream_body } => {
|
||||
let mut error = provider_admin_error(ProviderAdminErrorKind::CredentialRefreshRequired);
|
||||
let mut error = provider_admin_error(ProviderAdminErrorKind::CredentialRefreshRequired)
|
||||
.with_public_message("OpenAI 资料查询需要有效凭据,请刷新令牌后重试");
|
||||
if let Some(body) = upstream_body {
|
||||
error = error.with_message(format!(
|
||||
"OpenAI profile-statistics upstream returned HTTP 401: {}",
|
||||
@@ -1468,10 +1523,12 @@ fn map_profile_statistics_error(error: CodexProfileStatisticsError) -> ProviderA
|
||||
let retry_after = retry_after_seconds
|
||||
.map(|seconds| format!("; retry-after={seconds}s"))
|
||||
.unwrap_or_default();
|
||||
provider_admin_error(ProviderAdminErrorKind::BadGateway).with_message(format!(
|
||||
"OpenAI profile-statistics upstream returned HTTP {status}{retry_after}: {}",
|
||||
bounded_upstream_body(&body)
|
||||
))
|
||||
provider_admin_error(ProviderAdminErrorKind::BadGateway)
|
||||
.with_public_message("OpenAI 资料查询失败,请检查账号授权与上游服务")
|
||||
.with_message(format!(
|
||||
"OpenAI profile-statistics upstream returned HTTP {status}{retry_after}: {}",
|
||||
bounded_upstream_body(&body)
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1543,10 +1600,17 @@ fn bounded_upstream_body(body: &str) -> String {
|
||||
|
||||
fn map_catalog_error(error: CodexCredentialCatalogError) -> ProviderAdminError {
|
||||
use CodexCredentialCatalogError as Error;
|
||||
provider_admin_error(match error {
|
||||
Error::InvalidCredentialData | Error::InvalidEtag => ProviderAdminErrorKind::Invalid,
|
||||
Error::NoEligibleCredential => ProviderAdminErrorKind::NotFound,
|
||||
Error::ConcurrentUpdate => ProviderAdminErrorKind::Conflict,
|
||||
Error::Upstream { .. } | Error::Cache => ProviderAdminErrorKind::Unavailable,
|
||||
})
|
||||
use ProviderAdminErrorKind as Kind;
|
||||
let (kind, message) = match error {
|
||||
Error::InvalidCredentialData => (Kind::Invalid, "OpenAI 模型查询凭据无效,请检查账号授权"),
|
||||
Error::InvalidEtag => (Kind::Invalid, "OpenAI 模型目录版本标识无效,请重新查询"),
|
||||
Error::NoEligibleCredential => (Kind::NotFound, "没有可用于查询 OpenAI 模型的账号"),
|
||||
Error::ConcurrentUpdate => (Kind::Conflict, "OpenAI 模型目录正在更新,请稍后重试"),
|
||||
Error::Upstream { .. } => (
|
||||
Kind::Unavailable,
|
||||
"OpenAI 模型查询失败,请检查出站连接与上游服务",
|
||||
),
|
||||
Error::Cache => (Kind::Unavailable, "OpenAI 模型目录缓存暂不可用"),
|
||||
};
|
||||
provider_admin_error(kind).with_public_message(message)
|
||||
}
|
||||
|
||||
@@ -339,11 +339,20 @@ pub enum CodexCredentialAdminError {
|
||||
#[error("Codex refresh lease is unavailable")]
|
||||
RefreshLeaseUnavailable,
|
||||
#[error("Codex refresh token was rejected")]
|
||||
RefreshRejected { message: Option<String> },
|
||||
RefreshRejected {
|
||||
code: Option<String>,
|
||||
message: Option<String>,
|
||||
},
|
||||
#[error("Codex account is banned")]
|
||||
AccountBanned { message: Option<String> },
|
||||
#[error("Codex refresh service is unavailable")]
|
||||
RefreshUnavailable,
|
||||
#[error("Codex refresh upstream returned HTTP {status}")]
|
||||
RefreshUpstream {
|
||||
status: u16,
|
||||
code: Option<String>,
|
||||
message: Option<String>,
|
||||
},
|
||||
#[error("Codex refresh send state is ambiguous")]
|
||||
RefreshAmbiguous { message: Option<String> },
|
||||
}
|
||||
@@ -352,8 +361,9 @@ impl CodexCredentialAdminError {
|
||||
#[must_use]
|
||||
pub fn upstream_message(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::RefreshRejected { message }
|
||||
Self::RefreshRejected { message, .. }
|
||||
| Self::AccountBanned { message }
|
||||
| Self::RefreshUpstream { message, .. }
|
||||
| Self::RefreshAmbiguous { message } => message.as_deref(),
|
||||
Self::PersonalAccessToken(_)
|
||||
| Self::InvalidInput
|
||||
@@ -1020,16 +1030,29 @@ fn china_rfc3339(value: DateTime<Utc>) -> String {
|
||||
|
||||
fn map_refresh_failure(error: RefreshFailure) -> CodexCredentialAdminError {
|
||||
match error {
|
||||
RefreshFailure::InvalidGrant { message, .. } => {
|
||||
CodexCredentialAdminError::RefreshRejected { message }
|
||||
RefreshFailure::InvalidGrant { message, upstream } => {
|
||||
CodexCredentialAdminError::RefreshRejected {
|
||||
code: upstream
|
||||
.as_ref()
|
||||
.and_then(|failure| failure.code())
|
||||
.map(str::to_owned),
|
||||
message,
|
||||
}
|
||||
}
|
||||
RefreshFailure::Banned { message, .. } => {
|
||||
CodexCredentialAdminError::AccountBanned { message }
|
||||
}
|
||||
RefreshFailure::RetryableTransport { .. } => CodexCredentialAdminError::RefreshUnavailable,
|
||||
RefreshFailure::Transport { message, .. } => {
|
||||
CodexCredentialAdminError::RefreshAmbiguous { message }
|
||||
}
|
||||
// Worker 的 Transport 分类还承担 401 退避;管理提示只按已收到的响应事实细分,
|
||||
// 不改变后台刷新策略,也不把明确失败响应误报为租约冲突或执行结果未知。
|
||||
RefreshFailure::Transport { message, upstream } => match upstream {
|
||||
Some(upstream) => CodexCredentialAdminError::RefreshUpstream {
|
||||
status: upstream.status(),
|
||||
code: upstream.code().map(str::to_owned),
|
||||
message,
|
||||
},
|
||||
None => CodexCredentialAdminError::RefreshAmbiguous { message },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -398,7 +398,7 @@ impl AuthorizationCommitGuard for CodexOAuthAuthorizationCommitGuard {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AdminError::unavailable(
|
||||
"OpenAI OAuth pending claim could not be released",
|
||||
"OpenAI 授权处理状态暂时无法释放,请先检查授权状态",
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -897,7 +897,7 @@ fn map_authorization_settlement_error(error: CodexOAuthPendingStoreError) -> Adm
|
||||
AdminError::conflict("OpenAI OAuth pending claim conflicts with current state")
|
||||
}
|
||||
CodexOAuthPendingStoreError::Unavailable => {
|
||||
AdminError::unavailable("OpenAI OAuth pending claim store is unavailable")
|
||||
AdminError::unavailable("OpenAI 授权状态存储暂不可用,请稍后重试")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1603,3 +1603,315 @@ impl OAuthPendingFlowPort for TestOAuthPending {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
mod errors {
|
||||
use gateway_admin::ports::provider::ProviderAdminErrorKind as Kind;
|
||||
use gateway_core::provider_ports::{
|
||||
ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest, ProviderSchedulingState,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_refresh_preserves_banned_evidence_without_promoting_401_to_terminal() {
|
||||
for (status, kind, message) in [
|
||||
(400, Kind::Invalid, "OpenAI 账号已被停用,请检查账号状态"),
|
||||
(
|
||||
401,
|
||||
Kind::BadGateway,
|
||||
"OpenAI 拒绝了令牌刷新,请检查账号授权状态",
|
||||
),
|
||||
] {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(status).set_body_json(json!({
|
||||
"error": {
|
||||
"message": "account has been deactivated: raw-secret-marker"
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let (bundle, store, _config) = refresh_fixture(&server, false, true).await;
|
||||
let before = store.account("acct_refresh_error").unwrap();
|
||||
let error = bundle
|
||||
.admin_provider()
|
||||
.prepare_refresh(PrepareCredentialRefresh {
|
||||
account: account_record(&before),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), kind);
|
||||
assert_eq!(error.public_message(), Some(message));
|
||||
assert!(!format!("{error:?} {error}").contains("raw-secret-marker"));
|
||||
assert_eq!(store.account("acct_refresh_error").unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_refresh_reports_known_upstream_failures_without_changing_account_state() {
|
||||
for (status, code, expected_kind, expected_message) in [
|
||||
(
|
||||
401,
|
||||
"refresh_token_reused",
|
||||
Kind::BadGateway,
|
||||
"刷新令牌已被使用,请重新授权",
|
||||
),
|
||||
(
|
||||
401,
|
||||
"refresh_token_expired",
|
||||
Kind::BadGateway,
|
||||
"刷新令牌已过期,请重新授权",
|
||||
),
|
||||
(
|
||||
401,
|
||||
"refresh_token_invalidated",
|
||||
Kind::BadGateway,
|
||||
"刷新令牌已被撤销,请重新授权",
|
||||
),
|
||||
(
|
||||
401,
|
||||
"token_expired",
|
||||
Kind::BadGateway,
|
||||
"刷新令牌不可用,请重新授权",
|
||||
),
|
||||
(
|
||||
401,
|
||||
"unknown",
|
||||
Kind::BadGateway,
|
||||
"OpenAI 拒绝了令牌刷新,请检查账号授权状态",
|
||||
),
|
||||
(
|
||||
400,
|
||||
"refresh_token_reused",
|
||||
Kind::Invalid,
|
||||
"刷新令牌已被使用,请重新授权",
|
||||
),
|
||||
(
|
||||
400,
|
||||
"refresh_token_expired",
|
||||
Kind::Invalid,
|
||||
"刷新令牌已过期,请重新授权",
|
||||
),
|
||||
(
|
||||
400,
|
||||
"refresh_token_invalidated",
|
||||
Kind::Invalid,
|
||||
"刷新令牌已被撤销,请重新授权",
|
||||
),
|
||||
(
|
||||
400,
|
||||
"INVALID_GRANT",
|
||||
Kind::BadGateway,
|
||||
"刷新令牌无效或已失效,请重新授权",
|
||||
),
|
||||
(
|
||||
429,
|
||||
"unknown",
|
||||
Kind::BadGateway,
|
||||
"OpenAI 令牌刷新请求被限流,请稍后重试",
|
||||
),
|
||||
(
|
||||
503,
|
||||
"unknown",
|
||||
Kind::BadGateway,
|
||||
"OpenAI 令牌刷新服务异常,请稍后重试",
|
||||
),
|
||||
] {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(status).set_body_json(json!({
|
||||
"error": {"code": code, "message": "raw-secret-marker"}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let (bundle, store, _config) = refresh_fixture(&server, false, true).await;
|
||||
let before = store.account("acct_refresh_error").unwrap();
|
||||
let error = bundle
|
||||
.admin_provider()
|
||||
.prepare_refresh(PrepareCredentialRefresh {
|
||||
account: account_record(&before),
|
||||
})
|
||||
.await
|
||||
.expect_err("upstream rejection");
|
||||
assert_eq!(error.kind(), expected_kind, "HTTP {status} {code}");
|
||||
assert_eq!(error.public_message(), Some(expected_message));
|
||||
assert!(!format!("{error:?} {error}").contains("raw-secret-marker"));
|
||||
assert_eq!(store.account("acct_refresh_error").unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_refresh_distinguishes_busy_missing_token_and_stale_account_before_exchange() {
|
||||
for (busy, has_token, stale, kind, message) in [
|
||||
(
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
Kind::Conflict,
|
||||
"令牌刷新繁忙,请等待当前刷新完成后重试",
|
||||
),
|
||||
(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
Kind::Invalid,
|
||||
"账号没有刷新令牌,请重新授权",
|
||||
),
|
||||
(
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
Kind::Conflict,
|
||||
"账号凭据已被更新,请刷新账号列表后重试",
|
||||
),
|
||||
] {
|
||||
let server = MockServer::start().await;
|
||||
let (bundle, store, _config) = refresh_fixture(&server, busy, has_token).await;
|
||||
let before = store.account("acct_refresh_error").unwrap();
|
||||
let mut account = account_record(&before);
|
||||
if stale {
|
||||
account.upstream_user_id = Some("previous-user".to_owned());
|
||||
}
|
||||
let error = bundle
|
||||
.admin_provider()
|
||||
.prepare_refresh(PrepareCredentialRefresh { account })
|
||||
.await
|
||||
.expect_err("local refresh failure");
|
||||
assert_eq!(error.kind(), kind);
|
||||
assert_eq!(error.public_message(), Some(message));
|
||||
assert!(server.received_requests().await.unwrap().is_empty());
|
||||
assert_eq!(store.account("acct_refresh_error").unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_refresh_invalid_success_and_unclassified_transport_stay_conservative() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_string("invalid-success-secret-marker"),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let (bundle, store, mut config) = refresh_fixture(&server, false, true).await;
|
||||
let before = store.account("acct_refresh_error").unwrap();
|
||||
let command = || PrepareCredentialRefresh {
|
||||
account: account_record(&before),
|
||||
};
|
||||
let error = bundle
|
||||
.admin_provider()
|
||||
.prepare_refresh(command())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), Kind::Ambiguous);
|
||||
assert_eq!(
|
||||
error.public_message(),
|
||||
Some("令牌刷新结果未知,请先核对账号状态,不要立即重复刷新")
|
||||
);
|
||||
assert!(!format!("{error:?}").contains("invalid-success-secret-marker"));
|
||||
|
||||
config.config.auth.oauth_token_endpoint = "http://127.0.0.1:0/oauth/token".to_owned();
|
||||
let bundle =
|
||||
provider_openai::initialize(config.config, refresh_ports(store.clone(), false))
|
||||
.await
|
||||
.unwrap();
|
||||
let error = bundle
|
||||
.admin_provider()
|
||||
.prepare_refresh(command())
|
||||
.await
|
||||
.unwrap_err();
|
||||
// 既有 transport 策略未认定此错误为安全重试,本次不能因展示更详细而放宽重试边界。
|
||||
assert_eq!(error.kind(), Kind::Ambiguous);
|
||||
assert_eq!(
|
||||
error.public_message(),
|
||||
Some("令牌刷新结果未知,请先核对账号状态,不要立即重复刷新")
|
||||
);
|
||||
assert_eq!(store.account("acct_refresh_error").unwrap(), before);
|
||||
}
|
||||
|
||||
async fn refresh_fixture(
|
||||
server: &MockServer,
|
||||
busy: bool,
|
||||
has_token: bool,
|
||||
) -> (
|
||||
provider_openai::ProviderBundle,
|
||||
Arc<MemoryAccountStore>,
|
||||
TestOpenAiConfig,
|
||||
) {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let mut credential = secret("synthetic-refresh-access");
|
||||
if !has_token {
|
||||
credential.refresh_token = None;
|
||||
}
|
||||
store
|
||||
.seed_oauth_credential(ImportCodexOAuthCredential {
|
||||
account_id: "acct_refresh_error".to_owned(),
|
||||
name: "refresh error test".to_owned(),
|
||||
secret: credential,
|
||||
verified_account: profile("synthetic-refresh-user"),
|
||||
next_refresh_at: None,
|
||||
enabled: true,
|
||||
})
|
||||
.await;
|
||||
let mut config = valid_config();
|
||||
config.config.auth.oauth_token_endpoint = format!("{}/oauth/token", server.uri());
|
||||
let bundle =
|
||||
provider_openai::initialize(config.config.clone(), refresh_ports(store.clone(), busy))
|
||||
.await
|
||||
.unwrap();
|
||||
(bundle, store, config)
|
||||
}
|
||||
|
||||
fn refresh_ports(store: Arc<MemoryAccountStore>, busy: bool) -> ProviderStorePorts {
|
||||
ProviderStorePorts::new(
|
||||
store,
|
||||
Arc::new(RefreshLeases { busy }),
|
||||
Arc::new(MemorySessionAffinity::default()),
|
||||
Arc::new(MemorySessionExclusions::default()),
|
||||
Arc::new(TestCatalogCache::default()),
|
||||
Arc::new(TestArtifactProfiles),
|
||||
Arc::new(TestCredentialState),
|
||||
Arc::new(TestCooldown),
|
||||
Arc::new(TestRuntimePolicy),
|
||||
Arc::new(TestOAuthPending::default()),
|
||||
)
|
||||
}
|
||||
|
||||
struct RefreshLeases {
|
||||
busy: bool,
|
||||
}
|
||||
|
||||
impl ProviderLeasePort for RefreshLeases {
|
||||
fn load_state<'a>(
|
||||
&'a self,
|
||||
_: &'a ClientApiKeyId,
|
||||
_: &'a ProviderKind,
|
||||
_: &'a [ProviderAccountId],
|
||||
) -> BoxFuture<'a, Result<ProviderSchedulingState, ProviderStoreError>> {
|
||||
panic!("manual refresh does not use scheduling leases")
|
||||
}
|
||||
|
||||
fn try_acquire(
|
||||
&self,
|
||||
request: ProviderLeaseRequest,
|
||||
) -> BoxFuture<'_, Result<ProviderLeaseAcquisition, ProviderStoreError>> {
|
||||
assert!(matches!(
|
||||
request,
|
||||
ProviderLeaseRequest::Refresh(_) | ProviderLeaseRequest::RefreshCapacity(_)
|
||||
));
|
||||
Box::pin(async move {
|
||||
Ok(if self.busy {
|
||||
ProviderLeaseAcquisition::Busy { retry_after: None }
|
||||
} else {
|
||||
ProviderLeaseAcquisition::Acquired(Box::new(()))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ impl AuthorizationCommitGuard for XaiAuthorizationCommitGuard {
|
||||
OAuthPendingReleaseOutcome::NotFound
|
||||
| OAuthPendingReleaseOutcome::OwnerMismatch
|
||||
| OAuthPendingReleaseOutcome::ClaimMismatch => Err(AdminError::unavailable(
|
||||
"xAI OAuth pending claim could not be released",
|
||||
"xAI 授权处理状态暂时无法释放,请先检查授权状态",
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -611,7 +611,8 @@ impl ProviderAdmin for XaiAdminProvider {
|
||||
.await
|
||||
.map_err(map_store_error)?;
|
||||
if !account_matches_record(¤t.account, &command.account) {
|
||||
return Err(provider_error(ProviderAdminErrorKind::Conflict));
|
||||
return Err(provider_error(ProviderAdminErrorKind::Conflict)
|
||||
.with_public_message("账号凭据已被更新,请刷新账号列表后重试"));
|
||||
}
|
||||
let rotation: RotationDocument = serde_json::from_value(Value::Object(
|
||||
command.provider_material.into_provider_data().into_inner(),
|
||||
@@ -664,7 +665,8 @@ impl ProviderAdmin for XaiAdminProvider {
|
||||
.await
|
||||
.map_err(map_store_error)?;
|
||||
if !account_matches_record(¤t.account, &command.account) {
|
||||
return Err(provider_error(ProviderAdminErrorKind::Conflict));
|
||||
return Err(provider_error(ProviderAdminErrorKind::Conflict)
|
||||
.with_public_message("账号凭据已被更新,请刷新账号列表后重试"));
|
||||
}
|
||||
let prepared = self
|
||||
.refresh
|
||||
@@ -1347,15 +1349,28 @@ fn build_connection_test_operation(
|
||||
}
|
||||
|
||||
fn map_failure_class(class: FailureClass) -> ProviderAdminError {
|
||||
provider_error(match class {
|
||||
FailureClass::Transient => ProviderAdminErrorKind::Unavailable,
|
||||
FailureClass::Ambiguous => ProviderAdminErrorKind::Conflict,
|
||||
FailureClass::CredentialPermanent
|
||||
| FailureClass::ConfigurationPermanent
|
||||
| FailureClass::UserActionRequired
|
||||
| FailureClass::Security => ProviderAdminErrorKind::Invalid,
|
||||
FailureClass::Unsupported => ProviderAdminErrorKind::Unsupported,
|
||||
})
|
||||
use ProviderAdminErrorKind as Kind;
|
||||
let (kind, message) = match class {
|
||||
FailureClass::Transient => (
|
||||
Kind::Unavailable,
|
||||
"xAI 授权服务暂不可用,请检查出站连接后重试",
|
||||
),
|
||||
FailureClass::Ambiguous => (
|
||||
Kind::Ambiguous,
|
||||
"xAI 授权结果未知,请先核对账号状态,不要立即重复提交",
|
||||
),
|
||||
FailureClass::CredentialPermanent => (Kind::Invalid, "xAI 拒绝了当前凭据,请检查账号授权"),
|
||||
FailureClass::ConfigurationPermanent => {
|
||||
(Kind::Invalid, "xAI 授权配置无效,请检查客户端与授权配置")
|
||||
}
|
||||
FailureClass::UserActionRequired => (Kind::Invalid, "xAI 授权需要用户确认,请重新完成授权"),
|
||||
FailureClass::Security => (
|
||||
Kind::Invalid,
|
||||
"xAI 授权安全校验失败,请检查授权来源与账号信息",
|
||||
),
|
||||
FailureClass::Unsupported => (Kind::Unsupported, "xAI 不支持当前授权操作"),
|
||||
};
|
||||
provider_error(kind).with_public_message(message)
|
||||
}
|
||||
|
||||
fn map_oauth_error(error: OAuthError) -> ProviderAdminError {
|
||||
@@ -1379,7 +1394,7 @@ fn map_pending_claim_settlement_error(error: ProviderStoreError) -> AdminError {
|
||||
AdminError::conflict("xAI OAuth pending claim conflicts with current state")
|
||||
}
|
||||
ProviderStoreErrorKind::Unavailable => {
|
||||
AdminError::unavailable("xAI OAuth pending claim store is unavailable")
|
||||
AdminError::unavailable("xAI 授权状态存储暂不可用,请稍后重试")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1411,41 +1426,71 @@ fn map_repository_error(error: GrokCredentialRepositoryError) -> ProviderAdminEr
|
||||
}
|
||||
|
||||
fn map_refresh_error(error: GrokCredentialRefreshError) -> ProviderAdminError {
|
||||
use crate::GrokRefreshFailure as Failure;
|
||||
use GrokCredentialRefreshError as Error;
|
||||
match error {
|
||||
Error::Repository(error) => map_repository_error(error),
|
||||
Error::Lease(error) => map_provider_store_error(error),
|
||||
Error::LeaseBusy => provider_error(ProviderAdminErrorKind::Conflict),
|
||||
Error::InvalidRefreshResponse => provider_error(ProviderAdminErrorKind::Invalid),
|
||||
Error::Preparation => provider_error(ProviderAdminErrorKind::Unavailable),
|
||||
Error::ManualFailure(failure) => map_failure_class(match failure {
|
||||
crate::GrokRefreshFailure::Transient => FailureClass::Transient,
|
||||
crate::GrokRefreshFailure::Ambiguous => FailureClass::Ambiguous,
|
||||
crate::GrokRefreshFailure::InvalidGrant
|
||||
| crate::GrokRefreshFailure::Banned
|
||||
| crate::GrokRefreshFailure::Rejected => FailureClass::CredentialPermanent,
|
||||
}),
|
||||
}
|
||||
use ProviderAdminErrorKind as Kind;
|
||||
let (kind, message) = match error {
|
||||
Error::Repository(error) => return map_repository_error(error),
|
||||
Error::Lease(error) => return map_provider_store_error(error),
|
||||
Error::LeaseBusy => (Kind::Conflict, "令牌刷新繁忙,请等待当前刷新完成后重试"),
|
||||
Error::InvalidRefreshResponse => (
|
||||
Kind::Ambiguous,
|
||||
"xAI 返回的刷新凭据无效,请先核对账号状态,不要立即重复刷新",
|
||||
),
|
||||
Error::Preparation => (Kind::Unavailable, "xAI 令牌刷新服务尚未就绪,请稍后重试"),
|
||||
Error::ManualFailure(failure) => match failure {
|
||||
Failure::Transient => (
|
||||
Kind::Unavailable,
|
||||
"xAI 令牌刷新连接失败,请检查出站连接后重试",
|
||||
),
|
||||
Failure::Ambiguous => (
|
||||
Kind::Ambiguous,
|
||||
"令牌刷新结果未知,请先核对账号状态,不要立即重复刷新",
|
||||
),
|
||||
Failure::InvalidGrant => (Kind::Invalid, "刷新令牌已失效,请重新授权"),
|
||||
Failure::Banned => (Kind::Invalid, "xAI 账号已被停用,请检查账号状态"),
|
||||
Failure::Rejected => (
|
||||
Kind::BadGateway,
|
||||
"xAI 未能完成令牌刷新,请检查授权配置与上游服务",
|
||||
),
|
||||
},
|
||||
};
|
||||
provider_error(kind).with_public_message(message)
|
||||
}
|
||||
|
||||
fn map_quota_error(error: GrokQuotaError) -> ProviderAdminError {
|
||||
use GrokQuotaError as Error;
|
||||
provider_error(match error {
|
||||
Error::AccountUnavailable => ProviderAdminErrorKind::NotFound,
|
||||
Error::StaleCredentialSnapshot => ProviderAdminErrorKind::Conflict,
|
||||
Error::InvalidData => ProviderAdminErrorKind::Invalid,
|
||||
Error::Upstream | Error::Store => ProviderAdminErrorKind::Unavailable,
|
||||
})
|
||||
use ProviderAdminErrorKind as Kind;
|
||||
let (kind, message) = match error {
|
||||
Error::AccountUnavailable => (Kind::NotFound, "没有可用于查询 xAI 额度的账号"),
|
||||
Error::StaleCredentialSnapshot => {
|
||||
(Kind::Conflict, "账号凭据已被更新,请刷新账号列表后重试")
|
||||
}
|
||||
Error::InvalidData => (Kind::Invalid, "xAI 额度数据无效,请检查账号授权"),
|
||||
Error::Upstream => (
|
||||
Kind::Unavailable,
|
||||
"xAI 额度查询失败,请检查出站连接与上游服务",
|
||||
),
|
||||
Error::Store => (Kind::Unavailable, "xAI 额度查询的依赖服务暂不可用"),
|
||||
};
|
||||
provider_error(kind).with_public_message(message)
|
||||
}
|
||||
|
||||
fn map_catalog_error(error: GrokCredentialCatalogError) -> ProviderAdminError {
|
||||
use GrokCredentialCatalogError as Error;
|
||||
provider_error(match error {
|
||||
Error::InvalidCredentialData | Error::ConflictingModelFacts => {
|
||||
ProviderAdminErrorKind::Invalid
|
||||
use ProviderAdminErrorKind as Kind;
|
||||
let (kind, message) = match error {
|
||||
Error::InvalidCredentialData => (Kind::Invalid, "xAI 模型查询凭据无效,请检查账号授权"),
|
||||
Error::ConflictingModelFacts => (Kind::Invalid, "xAI 模型目录信息不一致,请重新查询"),
|
||||
Error::NoEligibleCredential => (Kind::NotFound, "没有可用于查询 xAI 模型的账号"),
|
||||
Error::StaleCredentialSnapshot => {
|
||||
(Kind::Conflict, "账号凭据已被更新,请刷新账号列表后重试")
|
||||
}
|
||||
Error::NoEligibleCredential => ProviderAdminErrorKind::NotFound,
|
||||
Error::StaleCredentialSnapshot => ProviderAdminErrorKind::Conflict,
|
||||
Error::Upstream | Error::Cache | Error::Store => ProviderAdminErrorKind::Unavailable,
|
||||
})
|
||||
Error::Upstream => (
|
||||
Kind::Unavailable,
|
||||
"xAI 模型查询失败,请检查出站连接与上游服务",
|
||||
),
|
||||
Error::Cache | Error::Store => (Kind::Unavailable, "xAI 模型查询的依赖服务暂不可用"),
|
||||
};
|
||||
provider_error(kind).with_public_message(message)
|
||||
}
|
||||
|
||||
@@ -1016,3 +1016,167 @@ impl OAuthPendingFlowPort for TestOAuthPending {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
mod errors {
|
||||
use gateway_admin::ports::provider::ProviderAdminErrorKind as Kind;
|
||||
use gateway_core::account::OutboundProxy;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_refresh_keeps_expired_and_changed_credentials_distinct_without_network() {
|
||||
for expired in [true, false] {
|
||||
let store = MemoryProviderAccountStore::shared();
|
||||
let mut input = create_input("refresh_error", "synthetic-subject");
|
||||
if expired {
|
||||
let now = Utc::now();
|
||||
input.account.access_token_expires_at = now + chrono::Duration::seconds(1);
|
||||
input.account.refresh_token_expires_at = Some(now + chrono::Duration::seconds(2));
|
||||
}
|
||||
seed_input(&store, &input).await.unwrap();
|
||||
if expired {
|
||||
// 新导入不接受已过期凭据;等待合法存入的短期凭据自然过期,验证历史账号刷新。
|
||||
let remaining = input.account.refresh_token_expires_at.unwrap() - Utc::now();
|
||||
tokio::time::sleep(
|
||||
remaining.to_std().unwrap_or_default() + Duration::from_millis(10),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let before = store.account(&input.account_id).unwrap();
|
||||
let mut account = account_record(&before);
|
||||
if !expired {
|
||||
account.upstream_user_id = Some("previous-subject".to_owned());
|
||||
}
|
||||
let bundle = provider_xai::initialize(
|
||||
xai_config(),
|
||||
provider_ports_with(store.clone(), Arc::new(TestOAuthPending::default())),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let error = bundle
|
||||
.admin_provider()
|
||||
.prepare_refresh(PrepareCredentialRefresh { account })
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
if expired {
|
||||
Kind::Invalid
|
||||
} else {
|
||||
Kind::Conflict
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
error.public_message(),
|
||||
Some(if expired {
|
||||
"刷新令牌已失效,请重新授权"
|
||||
} else {
|
||||
"账号凭据已被更新,请刷新账号列表后重试"
|
||||
})
|
||||
);
|
||||
assert_eq!(store.account(&input.account_id).unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_refresh_reports_busy_without_calling_the_account_proxy() {
|
||||
let store = MemoryProviderAccountStore::shared();
|
||||
let input = create_input("refresh_busy", "synthetic-subject");
|
||||
seed_input(&store, &input).await.unwrap();
|
||||
// 账号使用出站代理时不提前准备直连 discovery;占用失败不得触发任何代理请求。
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let proxy =
|
||||
OutboundProxy::parse(&format!("http://{}", listener.local_addr().unwrap())).unwrap();
|
||||
store.set_outbound_proxy(&input.account_id, Some(proxy));
|
||||
let before = store.account(&input.account_id).unwrap();
|
||||
let mut account = account_record(&before);
|
||||
account.outbound_proxy = before.outbound_proxy().cloned();
|
||||
let ports = ProviderStorePorts::new(
|
||||
store.clone(),
|
||||
Arc::new(BusyRefreshLeases),
|
||||
Arc::new(TestSessionAffinity),
|
||||
Arc::new(TestSessionExclusions),
|
||||
Arc::new(TestCatalogCache::default()),
|
||||
Arc::new(TestArtifactProfiles),
|
||||
Arc::new(TestCredentialState),
|
||||
Arc::new(TestCooldown),
|
||||
Arc::new(TestRuntimePolicy),
|
||||
Arc::new(TestOAuthPending::default()),
|
||||
);
|
||||
let bundle = provider_xai::initialize(xai_config(), ports).await.unwrap();
|
||||
let error = bundle
|
||||
.admin_provider()
|
||||
.prepare_refresh(PrepareCredentialRefresh { account })
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), Kind::Conflict);
|
||||
assert_eq!(
|
||||
error.public_message(),
|
||||
Some("令牌刷新繁忙,请等待当前刷新完成后重试")
|
||||
);
|
||||
assert_eq!(
|
||||
listener.accept().unwrap_err().kind(),
|
||||
std::io::ErrorKind::WouldBlock
|
||||
);
|
||||
assert_eq!(store.account(&input.account_id).unwrap(), before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_refresh_unavailable_proxy_does_not_expose_proxy_credentials() {
|
||||
let store = MemoryProviderAccountStore::shared();
|
||||
let input = create_input("refresh_proxy", "synthetic-subject");
|
||||
seed_input(&store, &input).await.unwrap();
|
||||
let proxy =
|
||||
OutboundProxy::parse("http://synthetic-user:proxy-secret-marker@127.0.0.1:1").unwrap();
|
||||
store.set_outbound_proxy(&input.account_id, Some(proxy));
|
||||
let before = store.account(&input.account_id).unwrap();
|
||||
let mut account = account_record(&before);
|
||||
account.outbound_proxy = before.outbound_proxy().cloned();
|
||||
let bundle = provider_xai::initialize(
|
||||
xai_config(),
|
||||
provider_ports_with(store.clone(), Arc::new(TestOAuthPending::default())),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let error = bundle
|
||||
.admin_provider()
|
||||
.prepare_refresh(PrepareCredentialRefresh { account })
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), Kind::Unavailable);
|
||||
assert_eq!(
|
||||
error.public_message(),
|
||||
Some("xAI 令牌刷新连接失败,请检查出站连接后重试")
|
||||
);
|
||||
assert!(!format!("{error:?} {error}").contains("proxy-secret-marker"));
|
||||
assert_eq!(store.account(&input.account_id).unwrap(), before);
|
||||
}
|
||||
|
||||
struct BusyRefreshLeases;
|
||||
|
||||
impl ProviderLeasePort for BusyRefreshLeases {
|
||||
fn load_state<'a>(
|
||||
&'a self,
|
||||
_: &'a ClientApiKeyId,
|
||||
_: &'a ProviderKind,
|
||||
_: &'a [ProviderAccountId],
|
||||
) -> BoxFuture<
|
||||
'a,
|
||||
Result<gateway_core::provider_ports::ProviderSchedulingState, ProviderStoreError>,
|
||||
> {
|
||||
panic!("manual refresh does not use scheduling state")
|
||||
}
|
||||
|
||||
fn try_acquire(
|
||||
&self,
|
||||
request: ProviderLeaseRequest,
|
||||
) -> BoxFuture<'_, Result<ProviderLeaseAcquisition, ProviderStoreError>> {
|
||||
assert!(matches!(
|
||||
request,
|
||||
ProviderLeaseRequest::RefreshCapacity(_) | ProviderLeaseRequest::Refresh(_)
|
||||
));
|
||||
Box::pin(async { Ok(ProviderLeaseAcquisition::Busy { retry_after: None }) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -99,6 +99,21 @@ body 不进入这个通用信封。稳定业务码如下:
|
||||
未知 `/api/admin/*` 路径使用 `40401`,不会落入 SPA;已存在路径使用错误 method 时返回 `405`、
|
||||
`40001`,并保留标准 `Allow` header。request ID 继续通过配置的响应 header 返回。
|
||||
|
||||
Provider 管理适配使用静态 `public_message` 提供可操作的具体原因,Admin 用例完成安全消息选择后,
|
||||
API 对 `50201`、`50202` 和 `50301` 也保留该消息,不再用固定错误覆盖;缺少安全消息时仍回退到通用提示。
|
||||
认证错误和未知内部错误继续使用固定文案,不公开 Provider 内部 message、原始响应或凭据。
|
||||
|
||||
手动刷新令牌时,容量/账号租约占用和账号快照冲突仍为 `40901`,但分别提示等待或刷新账号列表。
|
||||
OpenAI 已收到的刷新失败响应不再统一归为资源冲突:原先落入宽泛 Transport 分类的明确拒绝使用
|
||||
`50201`,按已解析的错误码区分令牌过期、已使用、已撤销和 `invalid_grant`;刷新接口返回
|
||||
`token_expired` 时提示“刷新令牌不可用,请重新授权”,不据此断言具体失效原因。无法确认刷新结果时使用
|
||||
`50202`,提示先核对账号状态、不要立即重复刷新。缺少刷新令牌及原有明确凭据无效分支仍为 `40001`。
|
||||
上游 `401` 不代表管理员会话失效,也不会触发管理端重新登录。上述变化只修正管理错误的分类和展示,
|
||||
不改变后台自动刷新、401 恢复退避或账号终态策略;客户端不得仅因状态码从 `409` 改为 `502` 自动重发刷新。
|
||||
xAI 手动刷新返回无效的新凭据时,从 `40001` 改为 `50202`,因为上游可能已经轮换了旧 RT;
|
||||
未能完成刷新、但没有明确凭据永久失效证据的 `Rejected` 从 `40001` 改为 `50201`,不再一概提示凭据无效。
|
||||
Codex PAT 验证服务不可用和身份响应无效分别通过 `50301`、`50201` 保留原有具体提示。
|
||||
|
||||
### 管理写入一致性
|
||||
|
||||
管理写入不要求客户端提供全局配置版本。会改变路由快照或安全配置的写入由后端在事务内推进
|
||||
|
||||
@@ -249,6 +249,13 @@ Proxy changes advance the runtime configuration revision without invalidating in
|
||||
3. **原始上游诊断**:只在连接测试、运维错误详情等明确诊断界面展示已经捕获的原始字段,不翻译、不补造,
|
||||
也不塞入普通 Admin 错误信封。原始 body 不进入 Debug、普通日志或持久化错误消息。
|
||||
|
||||
Provider 管理适配在仍持有结构化失败事实时选择静态 `public_message`;Admin 将其转为可安全展示的
|
||||
`AdminError.message`,API 保留其中的 502/503 具体原因,无公开消息时才使用通用回退。认证与未知
|
||||
内部异常仍保持固定文案,不能把任意 Provider/Store 的 message 直接放行。
|
||||
管理提示与 Worker 的处置分类是不同职责:例如 OpenAI 刷新已收到 401 时,可以提示已解析的令牌
|
||||
拒绝原因,但不因此改变现有有界恢复退避和账号终态判定。刷新繁忙、已知上游失败与结果未知分别映射为
|
||||
409、50201、50202;结果未知不能触发自动重放一次性凭据,Vue 也不重新解析上游错误码或正文。
|
||||
|
||||
连接测试的 `gateway` / `provider` / `upstream` 来源以及 `not_sent` / `sent` / `ambiguous` 发送状态由 Core 在
|
||||
仍持有完整执行错误时一次判定;Vue 只能根据稳定字段生成摘要,不能匹配英文错误句子反推来源。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user