From 958e13c370d05e005daa931ba9b98cd5a9c2c312 Mon Sep 17 00:00:00 2001 From: Zyy Date: Thu, 29 Oct 2026 01:38:22 +0800 Subject: [PATCH] fix(errors): standardize client messages and correct error mappings Return non-retryable client version errors and correct provider error types. Keep safe user messages separate from operational diagnostics. Unify error wording and document the conventions in the development skill. Co-authored-by: Codex --- .agents/skills/cpr-dev-guide/SKILL.md | 21 ++++ backend/crates/gateway-admin/src/model/mod.rs | 14 ++- .../gateway-admin/src/model/quota_forecast.rs | 22 ++-- .../gateway-admin/src/ports/provider.rs | 4 +- .../crates/gateway-admin/src/ports/system.rs | 34 +++++- .../src/use_case/accounts/mod.rs | 16 +-- .../gateway-admin/src/use_case/backup.rs | 22 ++-- .../gateway-admin/src/use_case/client_keys.rs | 14 +-- .../src/use_case/credential_mutation.rs | 8 +- .../gateway-admin/src/use_case/error.rs | 20 ++-- .../src/use_case/plugin_accounts.rs | 2 +- .../src/use_case/plugins/distribution.rs | 8 +- .../gateway-admin/src/use_case/settings.rs | 10 +- .../gateway-admin/src/use_case/system.rs | 13 +-- .../src/use_case/usage_data/pricing.rs | 8 +- .../tests/use_case/accounts/mod.rs | 2 +- .../gateway-admin/tests/use_case/system.rs | 31 ++++++ .../gateway-api/src/admin/client_keys.rs | 25 ++++- .../crates/gateway-api/src/admin/settings.rs | 14 +-- .../crates/gateway-api/src/openai/error.rs | 17 +-- .../gateway-api/src/openai/images/http.rs | 2 +- .../gateway-api/src/openai/images/request.rs | 24 ++-- .../gateway-api/src/openai/live/http.rs | 16 +-- .../crates/gateway-api/src/openai/live/mod.rs | 10 +- .../gateway-api/src/openai/live/websocket.rs | 2 +- .../gateway-api/src/openai/middleware/http.rs | 4 +- .../crates/gateway-api/src/openai/models.rs | 4 +- .../gateway-api/src/openai/responses/http.rs | 4 +- .../src/openai/responses/keepalive.rs | 2 +- .../src/openai/responses/websocket/forward.rs | 6 +- .../src/openai/responses/websocket/mod.rs | 6 +- .../gateway-api/src/openai/search/http.rs | 2 +- .../crates/gateway-api/src/openai/service.rs | 4 +- .../crates/gateway-api/src/openai/usage.rs | 4 +- backend/crates/gateway-api/src/provider.rs | 18 +-- .../gateway-api/tests/admin/accounts/mod.rs | 4 +- .../crates/gateway-api/tests/admin/errors.rs | 68 +++++++++++- .../crates/gateway-api/tests/openai/auth.rs | 5 +- .../crates/gateway-api/tests/openai/error.rs | 22 ++-- .../crates/gateway-api/tests/openai/models.rs | 6 +- .../tests/openai/responses/http.rs | 6 +- .../openai/responses/websocket/forward.rs | 7 +- .../tests/openai/responses/websocket/mod.rs | 2 +- .../crates/gateway-core/src/concurrency.rs | 4 +- .../gateway-core/src/engine/coordinator.rs | 14 +-- .../src/engine/execution/contract.rs | 11 +- .../src/engine/execution/failure.rs | 26 ++--- .../src/engine/execution/scope.rs | 4 +- .../src/engine/execution/service.rs | 105 +++++++++--------- .../src/engine/image_generation.rs | 2 +- backend/crates/gateway-core/src/error/mod.rs | 37 +++--- backend/crates/gateway-core/src/lifecycle.rs | 14 +-- .../tests/engine/execution/mod.rs | 2 +- .../crates/gateway-core/tests/error/mod.rs | 6 +- backend/crates/gateway-host/src/lib.rs | 1 + .../gateway-host/src/system_update/archive.rs | 63 +++++------ .../gateway-host/src/system_update/config.rs | 6 +- .../src/system_update/diagnostics.rs | 57 ++++++++++ .../src/system_update/download.rs | 44 ++++---- .../gateway-host/src/system_update/error.rs | 10 ++ .../src/system_update/installation.rs | 3 +- .../gateway-host/src/system_update/mod.rs | 1 + .../src/system_update/operations.rs | 103 +++++++++++------ .../gateway-host/src/system_update/process.rs | 12 +- .../gateway-host/src/system_update/release.rs | 48 ++++---- .../gateway-host/src/system_update/state.rs | 58 +++++----- .../gateway-host/src/system_update/swap.rs | 72 +++++------- .../tests/system_update/events.rs | 75 +++++++++++++ .../gateway-host/tests/system_update/mod.rs | 26 +++++ .../tests/system_update/release.rs | 31 +++++- .../gateway-host/tests/system_update/state.rs | 21 ++++ .../src/adapter/middleware/outbound.rs | 2 +- .../runtime/src/adapter/middleware/request.rs | 2 +- .../runtime/src/callback/error.rs | 2 +- .../src/postgres/client_budgets.rs | 35 +++--- .../providers/openai/src/provider/failure.rs | 19 +++- .../providers/openai/src/provider/live.rs | 52 ++++++--- .../openai/src/provider/observation.rs | 34 +++--- .../src/transport/protocol/responses.rs | 3 +- .../openai/tests/provider/contract/mod.rs | 2 +- .../openai/tests/provider/contract/privacy.rs | 12 ++ .../provider/contract/session_binding.rs | 5 +- .../providers/openai/tests/provider/live.rs | 68 ++++++++++++ .../providers/xai/src/provider/failure.rs | 14 +-- .../providers/xai/src/transport/canonical.rs | 13 +-- .../xai/src/transport/request/mod.rs | 8 +- .../xai/tests/provider/contract/mod.rs | 4 +- .../xai/tests/transport/canonical.rs | 74 +++++++----- deploy/README.md | 3 +- docs/api.md | 10 +- docs/architecture.md | 2 +- 91 files changed, 1144 insertions(+), 609 deletions(-) create mode 100644 backend/crates/gateway-host/src/system_update/diagnostics.rs diff --git a/.agents/skills/cpr-dev-guide/SKILL.md b/.agents/skills/cpr-dev-guide/SKILL.md index eafd34cf6..b4a1b0cb7 100644 --- a/.agents/skills/cpr-dev-guide/SKILL.md +++ b/.agents/skills/cpr-dev-guide/SKILL.md @@ -17,6 +17,7 @@ description: Codex Proxy RS 开发、修复、排障、代码审查和文档修 | 开发、排查或审查插件接入、宿主 SDK、Runtime、扩展接口或独立网关插件;目标在 `backend/crates/gateway-plugin/`、`modules/plugins/` | 完整读取[插件职责边界](references/plugin-boundaries.md)及所用 [SDK](../../../backend/crates/gateway-plugin/sdk/README.md) 类型与能力合同;宿主维护自身合同、资源与稳定性,插件业务错误在插件侧修复,不在宿主、SDK 或 Runtime 中补偿;独立插件开发、排查、审查另用 [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md) | | 开发、排查或审查服务端接口、路由、调度、账号与用量规则、持久化、后台任务或协议行为;目标是 `backend/` 中的实现、配置、迁移,或 Rust 源码 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)的模块与依赖规则、所属模块及[后端自审](../../../docs/architecture.md#后端自审);Rust 使用 `$rust-best-practices` 和下方后端注释要求,宿主 Rust 同时读取 [Rust 模块组织约定](../../../docs/architecture.md#32-rust-模块组织约定) | | 修改页面接口调用,或开发、排查、审查面向管理端或模型客户端的 HTTP/WS API 合同 | [API](../../../docs/api.md)对应章节;优先复用已有接口,核对调用方 → 服务端解析 → 实际查询或执行,不能只凭前端类型认定参数生效;按实际改动叠加前后端规范 | +| 新增、修改或审查客户端可见错误、失败提示、后台任务状态文案 | 下方[错误文案](#错误文案),并按实际出口核对[错误与诊断三层边界](../../../docs/architecture.md#错误与诊断三层边界) | | 模块或调用链变更 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)及所属模块章节,不通读整份架构 | | 新增、调整、清理或审查测试 | 下方[测试维护](#测试维护),按当前行为、职责归属与已有覆盖判断保留、合并或删除 | | 修改部署行为、`deploy/` 配置或数据库迁移 | 按范围读取[部署](../../../deploy/README.md)、[迁移](../../../backend/migrations/README.md)对应章节 | @@ -55,3 +56,23 @@ description: Codex Proxy RS 开发、修复、排障、代码审查和文档修 - 每个 Rust 文件顶层使用简短的中文 `//!` 注释说明文件职责,覆盖生产源码、测试与构建脚本;测试文件说明测试范围或辅助用途,生成文件同步维护生成器 - 注释不使用中文或英文句号,多句说明按语义分行;保留 URL、版本号、标识符和代码示例中有语义的点号 - 文件说明聚焦当前功能与职责边界,局部注释解释原因和约束,避免复述代码或记录修改经过 + +## 错误文案 + +适用于项目自定义的客户端提示,包括 HTTP、WS、SSE 和任务失败状态;原始上游错误与内部诊断遵守[三层边界](../../../docs/architecture.md#错误与诊断三层边界),不为统一外观翻译、截短或改写 + +- 管理端自定义提示统一中文,模型 API 自定义提示统一英文;英文句首大写、句末使用句号,API、Codex 等缩写及专有名词保持正确大小写,中文短提示不加句末句号 +- 默认一句说明具体问题,确有必要才补第二句处理办法,不设机械字数限制;明确字段、对象或限制,避免只说「请求不合法」或堆叠内部术语 +- 处理办法必须明确且适用;不把「请稍后重试」「联系管理员」作为统一后缀,不罗列尚未确认的网络、权限、磁盘等可能原因,不对执行结果未知的操作笼统建议重试 +- 机器码、状态与结构化字段负责程序判断,文案供人阅读;只改措辞时保留分类、重试和账号处置语义,先核对现有调用方是否依赖消息文本 +- 在拥有错误事实的模块提供安全提示,保留独立的内部原因链;不在响应出口批量首字母大写、翻译或裁剪任意消息,以免改写上游原文 + +| 场景 | 文案示例 | +| --- | --- | +| 日费用额度耗尽 | `Daily spending limit reached.` | +| 请求过大 | `Request too large. Reduce the input size.` | +| 客户端过旧 | `Codex CLI is outdated. Upgrade to 0.152.0 or later.` | +| 名称为空 | `请输入 API Key 名称` | +| 更新包校验失败 | `更新包校验失败,请重新下载` | + +审查时沿实际出口检查语言、大小写、标点、长度和处理建议;复用现有行为测试核对错误码及敏感信息边界,不为逐条文案建立重复的快照或字符串清单测试 diff --git a/backend/crates/gateway-admin/src/model/mod.rs b/backend/crates/gateway-admin/src/model/mod.rs index 65213fd34..2a8a522b1 100644 --- a/backend/crates/gateway-admin/src/model/mod.rs +++ b/backend/crates/gateway-admin/src/model/mod.rs @@ -68,6 +68,7 @@ pub struct AdminError { kind: AdminErrorKind, message: String, source: Option, + sensitive_context_redacted: bool, } impl AdminError { @@ -77,6 +78,7 @@ impl AdminError { kind, message: message.into(), source: None, + sensitive_context_redacted: false, } } @@ -86,9 +88,19 @@ impl AdminError { self } + #[must_use] + pub fn redact_sensitive_context(mut self) -> Self { + self.sensitive_context_redacted = true; + self + } + #[must_use] pub fn error_details(&self) -> Option { - gateway_core::error::ErrorDetails::capture(self.source.as_ref(), None, false) + gateway_core::error::ErrorDetails::capture( + self.source.as_ref(), + None, + self.sensitive_context_redacted, + ) } #[must_use] diff --git a/backend/crates/gateway-admin/src/model/quota_forecast.rs b/backend/crates/gateway-admin/src/model/quota_forecast.rs index ca8f67c17..4c943bf8d 100644 --- a/backend/crates/gateway-admin/src/model/quota_forecast.rs +++ b/backend/crates/gateway-admin/src/model/quota_forecast.rs @@ -72,7 +72,7 @@ pub fn account_quota_forecasts( }, extrapolated: false, source: None, - unavailable_reason: Some("没有可统计的周/月额度窗口,请先刷新账号额度。"), + unavailable_reason: Some("缺少周/月额度窗口,请刷新账号额度"), low_sample: false, incomplete_cost: false, incomplete_tokens: false, @@ -138,30 +138,27 @@ impl AccountQuotaForecast { self.incomplete_tokens = usage.is_some_and(|usage| usage.missing_token_count > 0); let method = sample.map_or(QuotaForecastMethod::Cumulative, |sample| sample.method); let Some(start) = start.filter(|start| *start <= now && now < reset_at) else { - self.unavailable_reason = Some("额度窗口已过期或边界无效,请刷新账号额度后重试。"); + self.unavailable_reason = Some("额度窗口已过期或无效,请刷新账号额度"); return; }; if !observed_at.is_some_and(|observed| start <= observed && observed <= now) { - self.unavailable_reason = Some("缺少本周期的额度快照,请先刷新账号额度。"); + self.unavailable_reason = Some("缺少本周期的额度快照,请刷新账号额度"); return; } if sample.is_some_and(|sample| sample.discontinuous) { - self.unavailable_reason = - Some("额度观测出现回落或累计记录不连续,正在重新积累配对样本。"); + self.unavailable_reason = Some("额度回落或记录不连续,正在重新积累配对样本"); return; } if account_added_at > start && method == QuotaForecastMethod::Cumulative { - self.unavailable_reason = Some( - "本周期开始时的记录不完整,正在积累至少 5 个百分点的配对观测,无需等待下次重置。", - ); + self.unavailable_reason = Some("周期初记录不完整,正在积累至少 5 个百分点的配对观测"); return; } let Some(percent) = percent else { - self.unavailable_reason = Some("已用比例未知,请刷新额度后查看预测。"); + self.unavailable_reason = Some("已用比例未知,请刷新账号额度"); return; }; let Some(usage) = usage.filter(|usage| usage.request_count > 0) else { - self.unavailable_reason = Some("本周期没有网关用量记录,暂时无法预测额度。"); + self.unavailable_reason = Some("本周期没有网关用量记录,无法预测额度"); return; }; let Some(sample) = sample.filter(|sample| { @@ -172,8 +169,7 @@ impl AccountQuotaForecast { && sample.sampled_percent >= MIN_USED_PERCENT && sample.sampled_percent <= percent }) else { - self.unavailable_reason = - Some("有效额度进度不足 5 个百分点或采样边界无效,请继续积累用量。"); + self.unavailable_reason = Some("有效额度进度不足 5 个百分点或采样边界无效"); return; }; self.low_sample = sample.sampled_percent < LOW_SAMPLE_PERCENT @@ -196,7 +192,7 @@ impl AccountQuotaForecast { .and_then(|(used, remaining)| estimate(used + remaining, factor)); self.unavailable_reason = if self.estimated_tokens.is_none() && self.estimated_usd.is_none() { - Some("本周期暂无可用于估算的 Token 或费用数据,请积累用量后重试。") + Some("本周期缺少可用于估算的 Token 或费用数据") } else { None }; diff --git a/backend/crates/gateway-admin/src/ports/provider.rs b/backend/crates/gateway-admin/src/ports/provider.rs index 6f3808576..a784c4a8f 100644 --- a/backend/crates/gateway-admin/src/ports/provider.rs +++ b/backend/crates/gateway-admin/src/ports/provider.rs @@ -148,7 +148,7 @@ pub trait ProviderAdmin: Send + Sync { > { Err(gateway_core::settings::privacy::PrivacyError { rule_index: 0, - reason: "Provider 不支持隐私策略", + reason: "该服务商不支持隐私策略", }) } @@ -161,7 +161,7 @@ pub trait ProviderAdmin: Send + Sync { > { Err(gateway_core::settings::privacy::PrivacyError { rule_index: 0, - reason: "Provider 不支持隐私策略", + reason: "该服务商不支持隐私策略", }) } diff --git a/backend/crates/gateway-admin/src/ports/system.rs b/backend/crates/gateway-admin/src/ports/system.rs index bdc34f529..d7ca09500 100644 --- a/backend/crates/gateway-admin/src/ports/system.rs +++ b/backend/crates/gateway-admin/src/ports/system.rs @@ -38,11 +38,13 @@ pub enum SystemOperationErrorKind { } /// 不泄漏路径、命令行或发布凭据的系统操作错误 -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[error("system operation failed: {message}")] +#[derive(Debug, Clone, thiserror::Error)] +#[error("{message}")] pub struct SystemOperationError { kind: SystemOperationErrorKind, message: String, + source: Option, + sensitive_context_redacted: bool, } impl SystemOperationError { @@ -51,9 +53,37 @@ impl SystemOperationError { Self { kind, message: message.into(), + source: None, + sensitive_context_redacted: false, } } + #[must_use] + pub fn with_source(mut self, source: impl Into) -> Self { + self.source = Some(source.into()); + self + } + + #[must_use] + pub fn redact_sensitive_context(mut self) -> Self { + self.sensitive_context_redacted = true; + self + } + + #[must_use] + pub const fn sensitive_context_redacted(&self) -> bool { + self.sensitive_context_redacted + } + + #[must_use] + pub fn error_details(&self) -> Option { + gateway_core::error::ErrorDetails::capture( + self.source.as_ref(), + None, + self.sensitive_context_redacted, + ) + } + #[must_use] pub const fn kind(&self) -> SystemOperationErrorKind { self.kind diff --git a/backend/crates/gateway-admin/src/use_case/accounts/mod.rs b/backend/crates/gateway-admin/src/use_case/accounts/mod.rs index 02e667205..91655a1a4 100644 --- a/backend/crates/gateway-admin/src/use_case/accounts/mod.rs +++ b/backend/crates/gateway-admin/src/use_case/accounts/mod.rs @@ -116,14 +116,14 @@ pub trait AccountsService: Send + Sync { &self, _account_id: &ProviderAccountId, ) -> Result { - Err(AdminError::invalid("当前 Provider 不支持个人信息")) + Err(AdminError::invalid("该服务商不支持个人信息查询")) } async fn profile_avatar( &self, _account_id: &ProviderAccountId, ) -> Result { - Err(AdminError::invalid("当前 Provider 不支持账号头像")) + Err(AdminError::invalid("该服务商不支持账号头像")) } async fn reset_credit_automation_status( @@ -154,7 +154,7 @@ pub trait AccountsService: Send + Sync { _context: &MutationContext, _account_id: ProviderAccountId, ) -> Result { - Err(AdminError::invalid("当前 Provider 不支持重置额度")) + Err(AdminError::invalid("该服务商不支持额度重置")) } async fn consume_reset_credit( @@ -162,7 +162,7 @@ pub trait AccountsService: Send + Sync { _context: &MutationContext, _command: ConsumeProviderResetCredit, ) -> Result { - Err(AdminError::invalid("当前 Provider 不支持重置额度")) + Err(AdminError::invalid("该服务商不支持额度重置")) } async fn models( @@ -315,7 +315,7 @@ impl DefaultAccountsService { .load_account(account_id.as_str(), runtime) .await .map_err(|error| map_store_error(error, "provider account"))? - .ok_or_else(|| AdminError::not_found("Provider 账号不存在")) + .ok_or_else(|| AdminError::not_found("服务商账号不存在")) } async fn provider_for_account( @@ -564,7 +564,7 @@ impl AccountsService for DefaultAccountsService { .zip(quotas) .map(|(mut item, quota)| { let id = ProviderAccountId::new(item.account.id.clone()) - .map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))?; + .map_err(|_| AdminError::invalid("服务商账号 ID 不合法"))?; let capabilities = providers .require(&item.account.provider_kind) .map(|provider| { @@ -743,7 +743,7 @@ impl AccountsService for DefaultAccountsService { command: UpdateAccount, ) -> Result { let account_id = ProviderAccountId::new(command.account_id.clone()) - .map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))?; + .map_err(|_| AdminError::invalid("服务商账号 ID 不合法"))?; let (_, provider) = self.provider_for_account(&account_id).await?; let enabled = command.enabled; let result = self @@ -771,7 +771,7 @@ impl AccountsService for DefaultAccountsService { .iter() .map(|id| { ProviderAccountId::new(id.clone()) - .map_err(|_| AdminError::invalid("Provider 账号 ID 不合法")) + .map_err(|_| AdminError::invalid("服务商账号 ID 不合法")) }) .collect::, _>>()?; let mut providers = BTreeMap::< diff --git a/backend/crates/gateway-admin/src/use_case/backup.rs b/backend/crates/gateway-admin/src/use_case/backup.rs index 7756fcb67..8f9475af3 100644 --- a/backend/crates/gateway-admin/src/use_case/backup.rs +++ b/backend/crates/gateway-admin/src/use_case/backup.rs @@ -326,17 +326,15 @@ impl DefaultBackupService { /// 校验存储配置;空串视为非法,Secret 缺省表示保留旧值 fn validate_storage(command: &UpdateBackupStorageCommand) -> Result<(), AdminError> { validate_endpoint(&command.endpoint)?; - validate_nonempty("region", &command.region)?; - validate_nonempty("bucket", &command.bucket)?; - validate_nonempty("accessKeyId", &command.access_key_id)?; + validate_nonempty("存储区域", &command.region)?; + validate_nonempty("存储桶名称", &command.bucket)?; + validate_nonempty("访问密钥 ID", &command.access_key_id)?; if let Some(secret) = command.secret_access_key.as_ref() { let value = secret.expose_secret(); if value.is_empty() { - return Err(AdminError::bad_request( - "secretAccessKey 不能为空字符串;缺省表示保留旧值", - )); + return Err(AdminError::bad_request("访问密钥不能为空")); } - validate_length("secretAccessKey", value, 512)?; + validate_length("访问密钥", value, 512)?; } crate::model::backup::normalize_prefix(&command.prefix).map_err(map_backup_error)?; Ok(()) @@ -344,17 +342,15 @@ fn validate_storage(command: &UpdateBackupStorageCommand) -> Result<(), AdminErr fn validate_endpoint(value: &str) -> Result<(), AdminError> { if value.is_empty() { - return Err(AdminError::bad_request("endpoint 不能为空")); + return Err(AdminError::bad_request("备份存储地址不能为空")); } if !value.starts_with("https://") { - return Err(AdminError::bad_request( - "endpoint 必须使用 HTTPS;仅测试或显式开发配置允许 HTTP", - )); + return Err(AdminError::bad_request("备份存储地址必须使用 HTTPS")); } if value.contains('@') || value.bytes().any(|byte| byte.is_ascii_control()) { - return Err(AdminError::bad_request("endpoint 格式非法")); + return Err(AdminError::bad_request("备份存储地址格式不合法")); } - validate_length("endpoint", value, 2048) + validate_length("备份存储地址", value, 2048) } fn validate_nonempty(field: &str, value: &str) -> Result<(), AdminError> { diff --git a/backend/crates/gateway-admin/src/use_case/client_keys.rs b/backend/crates/gateway-admin/src/use_case/client_keys.rs index ac28696bb..0a94bfd36 100644 --- a/backend/crates/gateway-admin/src/use_case/client_keys.rs +++ b/backend/crates/gateway-admin/src/use_case/client_keys.rs @@ -94,7 +94,7 @@ impl ClientKeyService for DefaultClientKeyService { .get_client_key(id) .await .map_err(|error| map_store_error(error, "client API key"))? - .ok_or_else(|| AdminError::not_found("Client API Key 不存在")) + .ok_or_else(|| AdminError::not_found("API Key 不存在")) } async fn budget(&self, id: &ClientApiKeyId) -> Result { @@ -149,7 +149,7 @@ impl ClientKeyService for DefaultClientKeyService { .reveal_client_key(id) .await .map_err(|error| map_store_error(error, "client API key"))? - .ok_or_else(|| AdminError::not_found("Client API Key 不存在")) + .ok_or_else(|| AdminError::not_found("API Key 不存在")) } async fn create( @@ -158,7 +158,7 @@ impl ClientKeyService for DefaultClientKeyService { command: CreateClientKey, ) -> Result { crate::model::client_keys::validate_group_ids(&command.group_ids) - .map_err(|_| AdminError::invalid("Client API Key 分组不合法"))?; + .map_err(|_| AdminError::invalid("API Key 分组不合法"))?; for (provider, profile) in &command.request_profile_overrides { self.providers .require(provider) @@ -202,7 +202,7 @@ impl ClientKeyService for DefaultClientKeyService { command: UpdateClientKey, ) -> Result { crate::model::client_keys::validate_group_ids(&command.group_ids) - .map_err(|_| AdminError::invalid("Client API Key 分组不合法"))?; + .map_err(|_| AdminError::invalid("API Key 分组不合法"))?; for (provider, profile) in &command.request_profile_override_updates { if let Some(profile) = profile { self.providers @@ -277,9 +277,7 @@ fn validate_cursor(query: &ClientKeyListQuery) -> Result<(), AdminError> { return Ok(()); }; if cursor.sort != query.sort { - return Err(AdminError::invalid( - "Client API Key 游标排序与查询条件不一致", - )); + return Err(AdminError::invalid("分页游标与排序条件不一致")); } let matches = matches!( (cursor.sort.field, &cursor.value), @@ -300,7 +298,7 @@ fn validate_cursor(query: &ClientKeyListQuery) -> Result<(), AdminError> { if matches { Ok(()) } else { - Err(AdminError::invalid("Client API Key 游标不合法")) + Err(AdminError::invalid("API Key 分页游标不合法")) } } diff --git a/backend/crates/gateway-admin/src/use_case/credential_mutation.rs b/backend/crates/gateway-admin/src/use_case/credential_mutation.rs index fcdc2833a..86891dcad 100644 --- a/backend/crates/gateway-admin/src/use_case/credential_mutation.rs +++ b/backend/crates/gateway-admin/src/use_case/credential_mutation.rs @@ -65,7 +65,7 @@ pub(super) async fn required_credential( .credential_details(provider_kind, account_id) .await .map_err(|error| map_store_error(error, resource))? - .ok_or_else(|| AdminError::not_found("Provider 凭据不存在")) + .ok_or_else(|| AdminError::not_found("服务商凭据不存在")) } pub(super) async fn required_plugin_credential( @@ -77,7 +77,7 @@ pub(super) async fn required_plugin_credential( .credential_details_by_id(account_id) .await .map_err(|error| map_store_error(error, resource))? - .ok_or_else(|| AdminError::not_found("Provider 凭据不存在")) + .ok_or_else(|| AdminError::not_found("服务商凭据不存在")) } pub(super) async fn import_proxy_binding( @@ -156,7 +156,7 @@ pub(super) fn validate_prepared_import( .iter() .any(|credential| credential.provider_kind != *provider_kind) { - return Err(AdminError::conflict("Provider 准备结果与请求范围不一致")); + return Err(AdminError::conflict("服务商返回的凭据与请求范围不符")); } Ok(()) } @@ -176,7 +176,7 @@ pub(super) fn validate_prepared_rotation_facts( if facts.account_id.as_str() != account.id.as_str() || facts.provider_kind != account.provider_kind { - return Err(AdminError::conflict("Provider 准备结果与当前凭据不一致")); + return Err(AdminError::conflict("凭据处理结果与当前凭据不符")); } Ok(()) } diff --git a/backend/crates/gateway-admin/src/use_case/error.rs b/backend/crates/gateway-admin/src/use_case/error.rs index 08f9065ef..8a17778dc 100644 --- a/backend/crates/gateway-admin/src/use_case/error.rs +++ b/backend/crates/gateway-admin/src/use_case/error.rs @@ -42,15 +42,17 @@ pub(super) fn map_provider_error( ProviderAdminErrorKind::BadGateway => AdminErrorKind::BadGateway, ProviderAdminErrorKind::Internal => AdminErrorKind::Internal, }; - let message = match kind { - AdminErrorKind::Invalid => "Provider 请求不合法", - AdminErrorKind::NotFound => "Provider 资源不存在", - AdminErrorKind::Conflict => "Provider 资源状态冲突,请刷新后重试", - AdminErrorKind::BadGateway => "上游服务请求失败", - AdminErrorKind::UpstreamResultUnknown => "上游执行结果未知,请刷新状态后再决定是否重试", - AdminErrorKind::Unavailable => "Provider 服务暂不可用", - AdminErrorKind::Internal => "服务内部错误", - _ => "Provider 操作失败", + let message = match error.kind() { + ProviderAdminErrorKind::Invalid => "服务商请求参数不合法", + ProviderAdminErrorKind::Unsupported => "该服务商不支持此操作", + ProviderAdminErrorKind::NotFound => "服务商资源不存在", + ProviderAdminErrorKind::Conflict => "服务商资源状态冲突,请刷新后重试", + ProviderAdminErrorKind::BadGateway => "上游服务请求失败", + ProviderAdminErrorKind::Ambiguous => "上游执行结果未知,请刷新状态后再决定是否重试", + ProviderAdminErrorKind::Unavailable | ProviderAdminErrorKind::CredentialRefreshRequired => { + "服务商暂不可用" + } + ProviderAdminErrorKind::Internal => "服务内部错误", }; AdminError::new(kind, error.public_message().unwrap_or(message)).with_source(OperationFailure { operation: resource, diff --git a/backend/crates/gateway-admin/src/use_case/plugin_accounts.rs b/backend/crates/gateway-admin/src/use_case/plugin_accounts.rs index a10955bcc..27dc69aab 100644 --- a/backend/crates/gateway-admin/src/use_case/plugin_accounts.rs +++ b/backend/crates/gateway-admin/src/use_case/plugin_accounts.rs @@ -104,7 +104,7 @@ impl PluginAccountAccess for DefaultPluginAccountAccess { .load_credential_for_plugin(account_id) .await .map_err(|error| map_store_error(error, "plugin credential"))? - .ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))?; + .ok_or_else(|| AdminError::not_found("服务商凭据不存在"))?; Ok(PluginAccountCredential { account: credential.account, provider_material: credential.provider_material, diff --git a/backend/crates/gateway-admin/src/use_case/plugins/distribution.rs b/backend/crates/gateway-admin/src/use_case/plugins/distribution.rs index c5dee471e..c18300be6 100644 --- a/backend/crates/gateway-admin/src/use_case/plugins/distribution.rs +++ b/backend/crates/gateway-admin/src/use_case/plugins/distribution.rs @@ -71,7 +71,7 @@ impl PluginsService { validate_update_policy(&binding)?; let PluginUpdateSource::Github { repository } = &binding.source else { return Err(AdminError::invalid( - "该来源不提供 Release 更新查询,请显式选择并安装插件包", + "该来源不支持更新查询,请手动安装插件包", )); }; let (tag, allow_prerelease) = match &binding.policy { @@ -356,9 +356,7 @@ fn validate_update_policy(binding: &PluginSourceBinding) -> Result<(), AdminErro if !matches!(binding.source, PluginUpdateSource::Github { .. }) && !matches!(binding.policy, PluginUpdatePolicy::Manual {}) { - return Err(AdminError::invalid( - "只有 GitHub 来源支持 Release 更新检查策略", - )); + return Err(AdminError::invalid("仅 GitHub 来源支持发行版本更新检查")); } if let PluginUpdatePolicy::Pinned { tag, .. } = &binding.policy && (tag.is_empty() @@ -366,7 +364,7 @@ fn validate_update_policy(binding: &PluginSourceBinding) -> Result<(), AdminErro || tag.chars().any(char::is_control) || tag.trim() != tag) { - return Err(AdminError::invalid("固定 Release tag 不合法")); + return Err(AdminError::invalid("固定版本标签不合法")); } Ok(()) } diff --git a/backend/crates/gateway-admin/src/use_case/settings.rs b/backend/crates/gateway-admin/src/use_case/settings.rs index 6eb176af2..fe8bb7831 100644 --- a/backend/crates/gateway-admin/src/use_case/settings.rs +++ b/backend/crates/gateway-admin/src/use_case/settings.rs @@ -47,14 +47,14 @@ pub trait SettingsService: Send + Sync { &self, _provider: &str, ) -> Result { - Err(AdminError::invalid("当前 Provider 不支持客户端身份配置")) + Err(AdminError::invalid("该服务商不支持客户端身份配置")) } async fn preview_client_profile( &self, _provider: &str, _configuration: Option<&gateway_core::account::OpaqueProviderData>, ) -> Result { - Err(AdminError::invalid("当前 Provider 不支持客户端身份配置")) + Err(AdminError::invalid("该服务商不支持客户端身份配置")) } async fn load(&self) -> Result; async fn replace( @@ -86,7 +86,7 @@ impl DefaultSettingsService { provider: &str, ) -> Result, AdminError> { let kind = gateway_core::routing::ProviderKind::new(provider) - .map_err(|_| AdminError::invalid("Provider 不合法"))?; + .map_err(|_| AdminError::invalid("服务商无效"))?; self.providers .require(&kind) .map_err(|error| super::map_provider_error(error, "client profile")) @@ -170,7 +170,7 @@ impl SettingsService for DefaultSettingsService { .keys() .any(|provider| !providers.contains_key(provider)) { - return Err(AdminError::invalid("Provider 不支持价目管理")); + return Err(AdminError::invalid("该服务商不支持价目管理")); } let mut current = self.pricing_source.fetch().await?; if current != command.preview { @@ -242,7 +242,7 @@ impl SettingsService for DefaultSettingsService { || model.chars().any(char::is_control) }) { - return Err(AdminError::invalid("Provider、模型 ID 或批量数量不合法")); + return Err(AdminError::invalid("服务商、模型 ID 或批量数量不合法")); } if command.change == PricingChange::Delete && command.models.iter().any(|model| { diff --git a/backend/crates/gateway-admin/src/use_case/system.rs b/backend/crates/gateway-admin/src/use_case/system.rs index bfbc4b1c7..bc83aaca1 100644 --- a/backend/crates/gateway-admin/src/use_case/system.rs +++ b/backend/crates/gateway-admin/src/use_case/system.rs @@ -146,12 +146,11 @@ fn map_system_error(error: SystemOperationError) -> AdminError { SystemOperationErrorKind::Upstream => AdminErrorKind::BadGateway, SystemOperationErrorKind::Internal => AdminErrorKind::Internal, }; - let message = match kind { - AdminErrorKind::Invalid => "系统操作请求不合法", - AdminErrorKind::Conflict => error.message(), - AdminErrorKind::BadGateway => "系统更新服务请求失败", - AdminErrorKind::Internal => "系统操作失败", - _ => "系统操作失败", + let failure = AdminError::new(kind, error.message()); + let failure = if error.sensitive_context_redacted() { + failure.redact_sensitive_context() + } else { + failure }; - AdminError::new(kind, message) + failure.with_source(error) } diff --git a/backend/crates/gateway-admin/src/use_case/usage_data/pricing.rs b/backend/crates/gateway-admin/src/use_case/usage_data/pricing.rs index a986d7763..d18b40bc0 100644 --- a/backend/crates/gateway-admin/src/use_case/usage_data/pricing.rs +++ b/backend/crates/gateway-admin/src/use_case/usage_data/pricing.rs @@ -24,9 +24,9 @@ impl DefaultUsageDataService { reason: reason.into(), }; let kind = ProviderKind::new(input.provider.clone()) - .map_err(|_| UsageDataError::invalid("Provider 无效"))?; + .map_err(|_| UsageDataError::invalid("服务商无效"))?; let Ok(provider) = self.providers.require(&kind) else { - return Ok(unavailable("Provider 没有可用的计价规则")); + return Ok(unavailable("服务商没有可用的计价规则")); }; let tokens = &input.tokens; let price = match &input.price { @@ -61,9 +61,9 @@ impl DefaultUsageDataService { ProviderAdminErrorKind::Unsupported | ProviderAdminErrorKind::Invalid ) => { - return Ok(unavailable("Provider 不支持该计价输入")); + return Ok(unavailable("服务商不支持该计价输入")); } - Err(_) => return Err(UsageDataError::unavailable("Provider 计价失败")), + Err(_) => return Err(UsageDataError::unavailable("服务商计价失败")), }; Ok(UsagePricingResult::Estimated { id: input.id, diff --git a/backend/crates/gateway-admin/tests/use_case/accounts/mod.rs b/backend/crates/gateway-admin/tests/use_case/accounts/mod.rs index 475e08d6b..429e6ad55 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts/mod.rs @@ -3088,7 +3088,7 @@ async fn accounts_refresh_should_not_expose_the_provider_failure_message() { .expect_err("Provider preparation must fail"); assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Conflict); - assert_eq!(error.to_string(), "Provider 资源状态冲突,请刷新后重试"); + assert_eq!(error.to_string(), "服务商资源状态冲突,请刷新后重试"); assert!(!error.to_string().contains(upstream_message)); assert_eq!( recorded(&events), diff --git a/backend/crates/gateway-admin/tests/use_case/system.rs b/backend/crates/gateway-admin/tests/use_case/system.rs index 08a46fc25..641c5dd97 100644 --- a/backend/crates/gateway-admin/tests/use_case/system.rs +++ b/backend/crates/gateway-admin/tests/use_case/system.rs @@ -16,11 +16,15 @@ use gateway_admin::{ struct RecordingSystemOperations { target: Mutex>>, channel: Mutex>, + failure: Option, } #[async_trait] impl SystemOperations for RecordingSystemOperations { async fn version(&self) -> Result { + if let Some(error) = &self.failure { + return Err(error.clone()); + } Ok(SystemVersion { version: "1.0.0".to_owned(), git_sha: "unknown".to_owned(), @@ -119,6 +123,33 @@ impl SystemOperations for RecordingSystemOperations { } } +#[tokio::test] +async fn system_failure_should_preserve_safe_message_source_and_redaction_marker() { + let operations = std::sync::Arc::new(RecordingSystemOperations { + failure: Some( + SystemOperationError::new( + gateway_admin::ports::system::SystemOperationErrorKind::Upstream, + "更新包下载失败", + ) + .with_source(std::io::Error::other("native transport diagnostic")) + .redact_sensitive_context(), + ), + ..RecordingSystemOperations::default() + }); + let services = super::AdminHarness::new().system(operations).build().await; + let error = services.system().version().await.expect_err("host failure"); + assert_eq!( + error.kind(), + gateway_admin::model::AdminErrorKind::BadGateway + ); + assert_eq!(error.message(), "更新包下载失败"); + assert!(!format!("{error} {error:?}").contains("native transport diagnostic")); + let details = error.error_details().expect("host error details"); + assert!(details.as_str().contains("native transport diagnostic")); + let details: serde_json::Value = serde_json::from_str(details.as_str()).expect("details JSON"); + assert_eq!(details["redacted"], true); +} + #[tokio::test] async fn system_update_should_normalize_blank_target_to_latest() { let operations = std::sync::Arc::new(RecordingSystemOperations::default()); diff --git a/backend/crates/gateway-api/src/admin/client_keys.rs b/backend/crates/gateway-api/src/admin/client_keys.rs index 03909274b..f92823772 100644 --- a/backend/crates/gateway-api/src/admin/client_keys.rs +++ b/backend/crates/gateway-api/src/admin/client_keys.rs @@ -1066,13 +1066,28 @@ fn mutation_response( } fn map_wire_error(error: WireValidationError) -> AdminError { - match error.field() { - "cursor" => AdminError::bad_request("Client API Key 游标不合法"), + let message = match error.field() { + "cursor" => "API Key 分页游标不合法", "clientKeyRevealNotFound" | "clientKeyMutationNotFound" => { - AdminError::not_found("Client API Key 不存在") + return AdminError::not_found("API Key 不存在"); } - _ => AdminError::bad_request("Client API Key 请求不合法"), - } + "id" => "API Key ID 不能为空", + "name" => "API Key 名称不能为空", + "label" => "API Key 标签不能为空", + "customKey" => "自定义 API Key 仅支持英文字母、数字和英文符号", + "groupIds" => "账号分组无效、重复或数量超限", + "maxConcurrency" => "并发请求上限超出支持范围", + "requestsPerMinute" => "每分钟请求上限超出支持范围", + "dailyLimitUsd" => "每日美元限额须非负,整数和小数各最多 10 位", + "weeklyLimitUsd" => "每周美元限额须非负,整数和小数各最多 10 位", + "providerRequestProfileOverrides" => "API Key 客户端身份配置无效或字段冲突", + "limit" => "每页数量须为 1 至 65535", + "search" => "搜索内容过长或包含控制字符", + "sortBy" => "排序字段不受支持", + "sortDirection" => "排序方向必须为 asc(升序)或 desc(降序)", + _ => "API Key 请求参数不合法", + }; + AdminError::bad_request(message) } fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError { diff --git a/backend/crates/gateway-api/src/admin/settings.rs b/backend/crates/gateway-api/src/admin/settings.rs index 8dbbb2837..5c8442104 100644 --- a/backend/crates/gateway-api/src/admin/settings.rs +++ b/backend/crates/gateway-api/src/admin/settings.rs @@ -581,13 +581,13 @@ fn wire_model_mappings(mappings: DomainModelMappings) -> ModelMappings { fn map_wire_error(error: WireValidationError) -> AdminError { let message = match error.field() { - "settingsRefreshConcurrencyOverflow" => "refreshConcurrency 不合法".to_owned(), - "settingsMaxConcurrencyOverflow" => "maxConcurrentPerAccount 不合法".to_owned(), - "settingsUsageRetentionOverflow" => "usageRetentionDays 不合法".to_owned(), - "settingsOpsRetentionOverflow" => "opsEventRetentionDays 不合法".to_owned(), - "settingsAuditRetentionOverflow" => "auditRetentionDays 不合法".to_owned(), - "requestLocation" => "请求位置不合法,请检查国家代码、地区和城市".to_owned(), - "providerRequestProfiles" => "Provider 请求画像格式不合法或字段冲突".to_owned(), + "settingsRefreshConcurrencyOverflow" => "刷新并发数不合法".to_owned(), + "settingsMaxConcurrencyOverflow" => "单账号并发上限不合法".to_owned(), + "settingsUsageRetentionOverflow" => "用量记录保留天数不合法".to_owned(), + "settingsOpsRetentionOverflow" => "运维事件保留天数不合法".to_owned(), + "settingsAuditRetentionOverflow" => "审计记录保留天数不合法".to_owned(), + "requestLocation" => "请求位置的国家代码、地区或城市不合法".to_owned(), + "providerRequestProfiles" => "服务商客户端身份配置无效或字段冲突".to_owned(), "minCodexDesktopVersion" => "Codex Desktop 最低版本格式不合法".to_owned(), "minCodexCliVersion" => "Codex CLI 最低版本格式不合法".to_owned(), field => format!("{field} 字段不合法"), diff --git a/backend/crates/gateway-api/src/openai/error.rs b/backend/crates/gateway-api/src/openai/error.rs index 2c8313b6a..939505e63 100644 --- a/backend/crates/gateway-api/src/openai/error.rs +++ b/backend/crates/gateway-api/src/openai/error.rs @@ -33,7 +33,8 @@ pub fn openai_error_response( } } -/// Codex 将这两个容量码视为不可重试;仅在交付边界转换,内部仍保留真实上游事实 +/// 无重试提示的容量拒绝及旧版 Codex 限流错误需要可重试投影 +/// 仅在交付边界转换,内部仍保留真实上游事实 pub(super) fn client_error_code(code: &str) -> &str { match code { "server_is_overloaded" | "slow_down" => "server_error", @@ -83,7 +84,7 @@ pub fn protocol_error_response(status: StatusCode, body: ProtocolErrorBody) -> R pub fn missing_client_api_key_response() -> (StatusCode, Json) { openai_error_response( StatusCode::UNAUTHORIZED, - "Missing or invalid API key", + "Missing or invalid API key.", "invalid_request_error", "invalid_api_key", ) @@ -93,7 +94,7 @@ pub fn missing_client_api_key_response() -> (StatusCode, Json) { pub fn runtime_unavailable_response() -> (StatusCode, Json) { openai_error_response( StatusCode::SERVICE_UNAVAILABLE, - "Gateway runtime configuration is temporarily unavailable", + "Gateway configuration is temporarily unavailable.", "server_error", "runtime_configuration_unavailable", ) @@ -110,7 +111,7 @@ pub fn client_version_rejection_response( let (message, code, current_version) = match rejection.current() { Some(current) => ( format!( - "{client_label} {current} is below the minimum required version {}. Upgrade {client_label} and retry.", + "{client_label} {current} is outdated. Upgrade to {} or later.", rejection.min() ), "client_version_too_old", @@ -118,15 +119,15 @@ pub fn client_version_rejection_response( ), None => ( format!( - "A valid {client_label} version is required. Upgrade to version {} or newer and retry.", - rejection.min() + "Cannot read the {client_label} version. Check the User-Agent and version headers." ), "client_version_unavailable", None, ), }; + // Codex 将 400 视为不可重试的请求错误,426 则会触发 WS 回退和 HTTP 重试 ( - StatusCode::UPGRADE_REQUIRED, + StatusCode::BAD_REQUEST, Json(json!({ "error": { "message": message, @@ -144,7 +145,7 @@ pub fn client_version_rejection_response( pub fn model_not_found_response() -> (StatusCode, Json) { openai_error_response( StatusCode::NOT_FOUND, - "The requested model does not exist or is not available", + "The requested model does not exist or is not available.", "invalid_request_error", "model_not_found", ) diff --git a/backend/crates/gateway-api/src/openai/images/http.rs b/backend/crates/gateway-api/src/openai/images/http.rs index 51eafb483..d2bc3d58c 100644 --- a/backend/crates/gateway-api/src/openai/images/http.rs +++ b/backend/crates/gateway-api/src/openai/images/http.rs @@ -124,7 +124,7 @@ async fn image_operation( .map_err(|_| { GatewayError::new( GatewayErrorKind::Internal, - "OpenAI protocol identifier is invalid", + "OpenAI protocol identifier is invalid.", ) })? .with_context(context); diff --git a/backend/crates/gateway-api/src/openai/images/request.rs b/backend/crates/gateway-api/src/openai/images/request.rs index 9f4b98656..1a97b621e 100644 --- a/backend/crates/gateway-api/src/openai/images/request.rs +++ b/backend/crates/gateway-api/src/openai/images/request.rs @@ -26,7 +26,7 @@ pub(super) async fn normalize_edit_body( return Ok(body); } if headers.get_all(CONTENT_TYPE).iter().count() != 1 { - return Err(invalid("Image edit requires a single Content-Type header")); + return Err(invalid("Image edit requires a single Content-Type header.")); } let mut request = Request::new(Body::from(body)); @@ -35,7 +35,7 @@ pub(super) async fn normalize_edit_body( DefaultBodyLimit::disable().apply(&mut request); let mut multipart = Multipart::from_request(request, &()) .await - .map_err(|_| invalid("Image edit multipart boundary is invalid"))?; + .map_err(|_| invalid("Image edit multipart boundary is invalid."))?; let mut body = Vec::new(); let mut serializer = serde_json::Serializer::new(&mut body); // 直接序列化字段,避免经过 Map 后覆盖同名参数 @@ -44,17 +44,17 @@ pub(super) async fn normalize_edit_body( while let Some(field) = multipart .next_field() .await - .map_err(|_| invalid("Image edit multipart body is invalid"))? + .map_err(|_| invalid("Image edit multipart body is invalid."))? { let name = field .name() - .ok_or_else(|| invalid("Image edit multipart fields must have names"))? + .ok_or_else(|| invalid("Image edit multipart fields must have names."))? .to_owned(); let is_file = field.file_name().is_some(); let bytes = field .bytes() .await - .map_err(|_| invalid("Image edit multipart field is invalid"))?; + .map_err(|_| invalid("Image edit multipart field is invalid."))?; if matches!(name.as_str(), "image" | "image[]") { // 按表单中的出现顺序追加,多图顺序参与编辑语义 images.push(image_reference(&bytes)?); @@ -65,7 +65,7 @@ pub(super) async fn normalize_edit_body( } else { if is_file { return Err(invalid( - "Only image, image[] and mask may contain image files", + "Only image, image[] and mask may contain image files.", )); } scalar_field(&name, &bytes)? @@ -76,7 +76,7 @@ pub(super) async fn normalize_edit_body( } if images.is_empty() { return Err(invalid( - "Image edit multipart requires an image or image[] upload", + "Image edit multipart requires an image or image[] upload.", )); } fields @@ -92,14 +92,14 @@ fn scalar_field(name: &str, bytes: &[u8]) -> Result { match name { "n" | "output_compression" | "partial_images" => serde_json::from_slice::(bytes) .map(Value::Number) - .map_err(|_| invalid("Image edit numeric fields must contain JSON numbers")), + .map_err(|_| invalid("Image edit numeric fields must contain JSON numbers.")), "stream" => serde_json::from_slice::(bytes) .map(Value::Bool) - .map_err(|_| invalid("Image edit stream field must contain a JSON boolean")), + .map_err(|_| invalid("Image edit stream field must contain a JSON boolean.")), // 其余表单字段保留名称与文本值,不因字段尚未识别而丢弃或拒绝 _ => { let text = std::str::from_utf8(bytes) - .map_err(|_| invalid("Image edit text fields must be valid UTF-8"))?; + .map_err(|_| invalid("Image edit text fields must be valid UTF-8."))?; Ok(Value::String(text.to_owned())) } } @@ -114,7 +114,7 @@ fn image_reference(bytes: &[u8]) -> Result { } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { "image/webp" } else { - return Err(invalid("Image edit uploads must be PNG, JPEG or WebP")); + return Err(invalid("Image edit uploads must be PNG, JPEG or WebP.")); }; Ok(json!({ "image_url": format!("data:{mime};base64,{}", STANDARD.encode(bytes)) })) } @@ -126,6 +126,6 @@ fn invalid(message: &'static str) -> GatewayError { fn encode_error(_: serde_json::Error) -> GatewayError { GatewayError::new( GatewayErrorKind::Internal, - "Could not encode image edit JSON", + "Could not encode image edit JSON.", ) } diff --git a/backend/crates/gateway-api/src/openai/live/http.rs b/backend/crates/gateway-api/src/openai/live/http.rs index cf471b8ce..e5ce707dc 100644 --- a/backend/crates/gateway-api/src/openai/live/http.rs +++ b/backend/crates/gateway-api/src/openai/live/http.rs @@ -70,7 +70,7 @@ pub(crate) async fn live_call( return live_error_response( LiveErrorShape::Live, StatusCode::BAD_REQUEST, - "Codex live call request has an invalid model", + "Codex live call request has an invalid model.", "invalid_request", ); } @@ -184,7 +184,7 @@ async fn read_call_body( Box::new(live_error_response( LiveErrorShape::Live, StatusCode::PAYLOAD_TOO_LARGE, - "Codex live request body too large", + "Codex live request body too large.", "invalid_request", )) })?; @@ -194,7 +194,7 @@ async fn read_call_body( Box::new(live_error_response( LiveErrorShape::Live, StatusCode::BAD_REQUEST, - "Codex live multipart body is invalid", + "Codex live multipart body is invalid.", "invalid_request", )) })?; @@ -205,7 +205,7 @@ async fn read_call_body( Box::new(live_error_response( LiveErrorShape::Live, StatusCode::BAD_REQUEST, - "Codex live multipart body is invalid", + "Codex live multipart body is invalid.", "invalid_request", )) })? @@ -218,7 +218,7 @@ async fn read_call_body( Box::new(live_error_response( LiveErrorShape::Live, StatusCode::BAD_REQUEST, - "Codex live multipart body is invalid", + "Codex live multipart body is invalid.", "invalid_request", )) })?; @@ -229,7 +229,7 @@ async fn read_call_body( Box::new(live_error_response( LiveErrorShape::Live, StatusCode::BAD_REQUEST, - "Codex live multipart body is invalid", + "Codex live multipart body is invalid.", "invalid_request", )) })?; @@ -237,7 +237,7 @@ async fn read_call_body( Box::new(live_error_response( LiveErrorShape::Live, StatusCode::BAD_REQUEST, - "Codex live session field must contain valid JSON", + "Codex live session field must contain valid JSON.", "invalid_request", )) })?; @@ -251,7 +251,7 @@ async fn read_call_body( None => Err(Box::new(live_error_response( LiveErrorShape::Live, StatusCode::BAD_REQUEST, - "Codex live multipart body requires an sdp field", + "Codex live multipart body requires an sdp field.", "invalid_request", ))), } diff --git a/backend/crates/gateway-api/src/openai/live/mod.rs b/backend/crates/gateway-api/src/openai/live/mod.rs index 7371e4429..d72c2e4c0 100644 --- a/backend/crates/gateway-api/src/openai/live/mod.rs +++ b/backend/crates/gateway-api/src/openai/live/mod.rs @@ -121,7 +121,7 @@ async fn sip_unsupported() -> Response { pub(crate) fn realtime_unsupported_response(capability: &str) -> Response { openai_error_response( StatusCode::NOT_IMPLEMENTED, - &format!("{capability} are not supported by the Codex OAuth upstream"), + &format!("Unsupported by Codex OAuth: {capability}."), "not_supported_error", "realtime_capability_not_supported", ) @@ -168,7 +168,7 @@ pub(crate) fn live_call_operation( Box::new(live_error_response( LiveErrorShape::Live, StatusCode::BAD_REQUEST, - "Codex live call request is invalid", + "Codex live call request is invalid.", "invalid_request", )) }; @@ -251,7 +251,7 @@ pub(crate) fn encode_call_request( )) }; if sdp.trim().is_empty() { - return Err(invalid("Codex live call request requires an SDP offer")); + return Err(invalid("Codex live call request requires an SDP offer.")); } let mut payload = serde_json::Map::new(); payload.insert("sdp".to_owned(), Value::String(sdp.to_owned())); @@ -260,7 +260,7 @@ pub(crate) fn encode_call_request( } serde_json::to_vec(&payload) .map(Bytes::from) - .map_err(|_| invalid("Codex live call request is invalid")) + .map_err(|_| invalid("Codex live call request is invalid.")) } /// 共用响应身份与传输边界;Live 原始正文另保留上游 Content-Type @@ -310,7 +310,7 @@ pub(crate) fn normalize_call_request( Box::new(live_error_response( LiveErrorShape::Live, StatusCode::BAD_REQUEST, - "Codex live call request requires a UTF-8 SDP offer", + "Codex live call request requires a UTF-8 SDP offer.", "invalid_request", )) })?; diff --git a/backend/crates/gateway-api/src/openai/live/websocket.rs b/backend/crates/gateway-api/src/openai/live/websocket.rs index 4caf31589..648ac7876 100644 --- a/backend/crates/gateway-api/src/openai/live/websocket.rs +++ b/backend/crates/gateway-api/src/openai/live/websocket.rs @@ -149,7 +149,7 @@ fn upgrade_required_response() -> Response { let mut response = live_error_response( LiveErrorShape::Realtime, StatusCode::UPGRADE_REQUIRED, - "WebSocket upgrade required", + "WebSocket upgrade required.", "websocket_upgrade_required", ); response.headers_mut().insert( diff --git a/backend/crates/gateway-api/src/openai/middleware/http.rs b/backend/crates/gateway-api/src/openai/middleware/http.rs index d220cc92e..b02ab2a81 100644 --- a/backend/crates/gateway-api/src/openai/middleware/http.rs +++ b/backend/crates/gateway-api/src/openai/middleware/http.rs @@ -246,7 +246,7 @@ pub(crate) fn error_response(error: MiddlewareError) -> Response { MiddlewareError::Rejected | MiddlewareError::Remote { rejected: true, .. } => { openai_error_response( StatusCode::FORBIDDEN, - "Request rejected by middleware", + "Request rejected by middleware.", "invalid_request_error", "middleware_rejected", ) @@ -255,7 +255,7 @@ pub(crate) fn error_response(error: MiddlewareError) -> Response { MiddlewareError::Fault | MiddlewareError::Remote { .. } | MiddlewareError::InvalidState => { openai_error_response( StatusCode::BAD_GATEWAY, - "Request middleware failed", + "Request middleware failed.", "server_error", "middleware_failed", ) diff --git a/backend/crates/gateway-api/src/openai/models.rs b/backend/crates/gateway-api/src/openai/models.rs index 8823bd1cf..ea71910f5 100644 --- a/backend/crates/gateway-api/src/openai/models.rs +++ b/backend/crates/gateway-api/src/openai/models.rs @@ -72,7 +72,7 @@ async fn models_response( { return openai_error_response( StatusCode::BAD_REQUEST, - "Invalid client_version", + "Invalid client_version.", "invalid_request_error", "invalid_client_version", ) @@ -134,7 +134,7 @@ async fn models_response( fn catalog_unavailable_response() -> Response { openai_error_response( StatusCode::SERVICE_UNAVAILABLE, - "Model catalog is temporarily unavailable", + "Model catalog is temporarily unavailable.", "server_error", "model_catalog_unavailable", ) diff --git a/backend/crates/gateway-api/src/openai/responses/http.rs b/backend/crates/gateway-api/src/openai/responses/http.rs index 2619d199b..f0f73d06b 100644 --- a/backend/crates/gateway-api/src/openai/responses/http.rs +++ b/backend/crates/gateway-api/src/openai/responses/http.rs @@ -984,7 +984,7 @@ impl MiddlewareBody for StreamingExecutionBody { MiddlewareError::Engine(error) => gateway_error_from_engine(&error), _ => GatewayError::new( GatewayErrorKind::Internal, - "gateway response stream could not be delivered", + "Gateway response stream could not be delivered.", ), }; self.push_stream_error(error); @@ -1068,6 +1068,6 @@ fn done_frame() -> MiddlewareFrame { fn invalid_execution_stream() -> MiddlewareError { MiddlewareError::Gateway(GatewayError::new( GatewayErrorKind::Internal, - "gateway response stream violated its execution lifecycle", + "Gateway response stream violated its execution lifecycle.", )) } diff --git a/backend/crates/gateway-api/src/openai/responses/keepalive.rs b/backend/crates/gateway-api/src/openai/responses/keepalive.rs index 7994c501b..6e58ae178 100644 --- a/backend/crates/gateway-api/src/openai/responses/keepalive.rs +++ b/backend/crates/gateway-api/src/openai/responses/keepalive.rs @@ -137,7 +137,7 @@ async fn stream_failure(response: Response, error_encoder: Option raw_message.unwrap_or_else(|| { status .canonical_reason() - .unwrap_or("Response stream failed") + .unwrap_or("Response stream failed.") }), ) }); diff --git a/backend/crates/gateway-api/src/openai/responses/websocket/forward.rs b/backend/crates/gateway-api/src/openai/responses/websocket/forward.rs index b353b7ed0..d32181b38 100644 --- a/backend/crates/gateway-api/src/openai/responses/websocket/forward.rs +++ b/backend/crates/gateway-api/src/openai/responses/websocket/forward.rs @@ -400,13 +400,13 @@ fn middleware_gateway_error(error: MiddlewareError) -> GatewayError { MiddlewareError::Rejected | MiddlewareError::Remote { rejected: true, .. } => { GatewayError::new( GatewayErrorKind::PolicyDenied, - "request middleware rejected the request", + "Request middleware rejected the request.", ) } MiddlewareError::Fault | MiddlewareError::Remote { .. } | MiddlewareError::InvalidState => { GatewayError::new( GatewayErrorKind::Internal, - "request middleware returned an invalid response", + "Request middleware returned an invalid response.", ) } } @@ -779,7 +779,7 @@ pub(super) async fn send_control( connection, &GatewayError::new( GatewayErrorKind::InvalidRequest, - "No upstream WebSocket connection is available for control messages", + "No upstream WebSocket connection is available for control messages.", ), request_id, ) diff --git a/backend/crates/gateway-api/src/openai/responses/websocket/mod.rs b/backend/crates/gateway-api/src/openai/responses/websocket/mod.rs index b4cc7c941..f8b242892 100644 --- a/backend/crates/gateway-api/src/openai/responses/websocket/mod.rs +++ b/backend/crates/gateway-api/src/openai/responses/websocket/mod.rs @@ -54,7 +54,7 @@ use forward::{ pub use protocol::{ResponseCreateFrameError, decode_response_create_with_context}; use protocol::{connection_limit_event, is_response_create}; -const TEXT_FRAMES_ONLY: &str = "Responses WebSocket accepts text frames only"; +const TEXT_FRAMES_ONLY: &str = "Responses WebSocket accepts text frames only."; const CONNECTION_LIMIT_CLOSE_REASON: &str = "Responses websocket connection limit reached"; /// 将已认证的 `GET /v1/responses` 升级为 Responses WebSocket @@ -288,13 +288,13 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke let current = execution.request_settings().ok_or_else(|| { gateway_core::error::GatewayError::new( gateway_core::error::GatewayErrorKind::Internal, - "runtime snapshot is unavailable", + "Gateway configuration is unavailable.", ) })?; let rebased = settings.rebase(current.snapshot()).map_err(|_| { gateway_core::error::GatewayError::new( gateway_core::error::GatewayErrorKind::InvalidRequest, - "request settings are invalid", + "Request settings are invalid.", ) })?; client = client.with_request_settings(rebased); diff --git a/backend/crates/gateway-api/src/openai/search/http.rs b/backend/crates/gateway-api/src/openai/search/http.rs index e437596f4..51ea8b6af 100644 --- a/backend/crates/gateway-api/src/openai/search/http.rs +++ b/backend/crates/gateway-api/src/openai/search/http.rs @@ -65,7 +65,7 @@ fn search_operation(body: Bytes, headers: &HeaderMap) -> Result::try_from_uri(&uri).is_err() { return openai_error_response( StatusCode::BAD_REQUEST, - "Usage query does not accept query parameters", + "Usage query does not accept query parameters.", "invalid_request_error", "invalid_usage_query", ) @@ -80,7 +80,7 @@ async fn usage_response( Err(_) => { return openai_error_response( StatusCode::SERVICE_UNAVAILABLE, - "Key usage is temporarily unavailable", + "Key usage is temporarily unavailable.", "server_error", "usage_unavailable", ) diff --git a/backend/crates/gateway-api/src/provider.rs b/backend/crates/gateway-api/src/provider.rs index 61be58896..8aa7ecc7c 100644 --- a/backend/crates/gateway-api/src/provider.rs +++ b/backend/crates/gateway-api/src/provider.rs @@ -73,18 +73,18 @@ async fn count_tokens( }; let provider = match ProviderKind::new(provider) { Ok(provider) => provider, - Err(_) => return invalid_request("provider identifier is invalid"), + Err(_) => return invalid_request("Provider identifier is invalid."), }; let model = match UpstreamModelId::new(model) { Ok(model) => model, - Err(_) => return invalid_request("provider model identifier is invalid"), + Err(_) => return invalid_request("Provider model identifier is invalid."), }; if serde_json::from_slice::(&body).is_err() { - return invalid_request("token count request must be valid JSON"); + return invalid_request("Token count request must be valid JSON."); } let payload = match RawJsonPayload::new(TOKEN_COUNT_PROTOCOL, body) { Ok(payload) => payload, - Err(_) => return invalid_request("token count request is invalid"), + Err(_) => return invalid_request("Token count request is invalid."), }; let (client_ip, user_agent) = request_client_context( &headers, @@ -124,12 +124,12 @@ async fn provider_http( body: Bytes, ) -> Response { if method == Method::GET && !body.is_empty() { - return invalid_request("GET provider HTTP operations cannot carry a request body"); + return invalid_request("GET provider HTTP operations cannot carry a request body."); } let method = match method { Method::GET => ProviderHttpMethod::Get, Method::POST => ProviderHttpMethod::Post, - _ => return invalid_request("provider HTTP method is unsupported"), + _ => return invalid_request("Provider HTTP method is unsupported."), }; let service = state.openai(); let client = match authenticate_client(service, &headers).await { @@ -138,7 +138,7 @@ async fn provider_http( }; let provider = match ProviderKind::new(provider) { Ok(provider) => provider, - Err(_) => return invalid_request("provider identifier is invalid"), + Err(_) => return invalid_request("Provider identifier is invalid."), }; let connection_options = headers .get_all(header::CONNECTION) @@ -158,7 +158,7 @@ async fn provider_http( .collect(); let payload = match RawHttpPayload::new(PROVIDER_HTTP_PROTOCOL, body) { Ok(payload) => payload, - Err(_) => return invalid_request("provider HTTP request is invalid"), + Err(_) => return invalid_request("Provider HTTP request is invalid."), }; let operation = match ProviderHttpRequest::new( endpoint, @@ -168,7 +168,7 @@ async fn provider_http( payload, ) { Ok(request) => Operation::ProviderHttp(request), - Err(_) => return invalid_request("provider HTTP request is invalid"), + Err(_) => return invalid_request("Provider HTTP request is invalid."), }; let (client_ip, user_agent) = request_client_context( &headers, diff --git a/backend/crates/gateway-api/tests/admin/accounts/mod.rs b/backend/crates/gateway-api/tests/admin/accounts/mod.rs index 92a1cc79c..935508161 100644 --- a/backend/crates/gateway-api/tests/admin/accounts/mod.rs +++ b/backend/crates/gateway-api/tests/admin/accounts/mod.rs @@ -69,7 +69,7 @@ mod personal_info { fn personal_info_response_preserves_unknown_subscription_and_profile_error() { let response = AccountPersonalInfoData::from(( AccountPersonalInfo { - profile: Err(AdminError::unavailable("Provider 服务暂不可用")), + profile: Err(AdminError::unavailable("服务商暂不可用")), subscription: None, }, gateway_api::TimePresenter::new(Default::default()), @@ -78,7 +78,7 @@ mod personal_info { serde_json::to_value(response).unwrap(), json!({ "profile": null, - "profileError": "Provider 服务暂不可用", + "profileError": "服务商暂不可用", "subscription": null, }) ); diff --git a/backend/crates/gateway-api/tests/admin/errors.rs b/backend/crates/gateway-api/tests/admin/errors.rs index 36f1c9f40..d4181b8f4 100644 --- a/backend/crates/gateway-api/tests/admin/errors.rs +++ b/backend/crates/gateway-api/tests/admin/errors.rs @@ -171,6 +171,70 @@ async fn malformed_admin_query_should_use_a_safe_bad_request_envelope() { ); } +#[tokio::test] +async fn client_key_validation_should_identify_the_invalid_field_without_echoing_values() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let secret = "private-key-must-not-leak\n"; + for (field, value, message) in [ + ("name", json!(" "), "API Key 名称不能为空"), + ( + "customKey", + json!(secret), + "自定义 API Key 仅支持英文字母、数字和英文符号", + ), + ( + "dailyLimitUsd", + json!("-1"), + "每日美元限额须非负,整数和小数各最多 10 位", + ), + ( + "weeklyLimitUsd", + json!("0.00000000001"), + "每周美元限额须非负,整数和小数各最多 10 位", + ), + ( + "maxConcurrency", + json!(u64::MAX), + "并发请求上限超出支持范围", + ), + ] { + for path in [ + "/api/admin/client-keys/create", + "/api/admin/client-keys/update", + ] { + if path.ends_with("/update") && field == "customKey" { + continue; + } + let mut payload = json!({ + "name": "key", + "groupIds": [], + "maxConcurrency": 0, + "requestsPerMinute": 0, + }); + if path.ends_with("/update") { + payload["id"] = json!("key_existing"); + } + payload[field] = value.clone(); + let mut request = request(Method::POST, path, Body::from(payload.to_string())); + request + .headers_mut() + .insert(header::COOKIE, SESSION_COOKIE.parse().unwrap()); + request + .headers_mut() + .insert(header::CONTENT_TYPE, "application/json".parse().unwrap()); + let response = app(fixture.state()).oneshot(request).await.unwrap(); + let (status, _, body) = response_json(response).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + body, + json!({"code": 40001, "message": message, "data": null}) + ); + assert!(!body.to_string().contains(secret.trim())); + } + } +} + #[tokio::test] async fn invalid_time_range_should_use_its_published_business_code() { let fixture = AdminTestFixture::new().await; @@ -445,8 +509,10 @@ mod provider { let fixture = AdminTestFixture::new().await; fixture.auth.insert_session("valid-session"); for (kind, status, code, message) in [ + (Kind::Invalid, 400, 40001, "服务商请求参数不合法"), + (Kind::Unsupported, 400, 40001, "该服务商不支持此操作"), (Kind::BadGateway, 502, 50201, "上游服务请求失败"), - (Kind::Unavailable, 503, 50301, "Provider 服务暂不可用"), + (Kind::Unavailable, 503, 50301, "服务商暂不可用"), ( Kind::Ambiguous, 502, diff --git a/backend/crates/gateway-api/tests/openai/auth.rs b/backend/crates/gateway-api/tests/openai/auth.rs index 2318b9919..0ccd6e564 100644 --- a/backend/crates/gateway-api/tests/openai/auth.rs +++ b/backend/crates/gateway-api/tests/openai/auth.rs @@ -562,9 +562,12 @@ async fn http_settings_freeze_before_plan_resolution_and_apply_before_admission( .unwrap(); assert_eq!( response.status(), - StatusCode::UPGRADE_REQUIRED, + StatusCode::BAD_REQUEST, "request changes must not alter the published baseline" ); + let body: serde_json::Value = + serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()).unwrap(); + assert_eq!(body["error"]["code"], "client_version_too_old"); assert_eq!( snapshots .acquire() diff --git a/backend/crates/gateway-api/tests/openai/error.rs b/backend/crates/gateway-api/tests/openai/error.rs index 80b36f8ff..89aa34656 100644 --- a/backend/crates/gateway-api/tests/openai/error.rs +++ b/backend/crates/gateway-api/tests/openai/error.rs @@ -357,7 +357,7 @@ fn local_provider_capacity_exhaustion_should_map_to_service_unavailable() { ); assert_eq!( projected.safe_message(), - "all eligible upstream accounts are temporarily busy" + "All eligible upstream accounts are temporarily busy." ); } @@ -389,7 +389,7 @@ fn no_eligible_provider_account_should_preserve_safe_provider_detail() { ); assert_eq!( gateway.safe_message(), - "no upstream provider is currently available for this request" + "No upstream provider is currently available for this request." ); } @@ -407,7 +407,7 @@ fn provider_infrastructure_failure_should_have_a_distinct_client_contract() { ); assert_eq!( projected.safe_message(), - "provider account infrastructure is temporarily unavailable" + "Upstream accounts are temporarily inaccessible." ); } @@ -422,7 +422,7 @@ fn no_eligible_provider_account_should_map_to_service_unavailable() { assert_eq!(projected.kind(), GatewayErrorKind::NoAvailableProvider); assert_eq!( projected.safe_message(), - "no upstream provider is currently available for this request" + "No upstream provider is currently available for this request." ); } @@ -479,7 +479,7 @@ fn engine_provider_credential_failures_should_not_impersonate_client_auth_failur assert_eq!(mapped.kind(), GatewayErrorKind::UpstreamUnavailable); assert_eq!( mapped.safe_message(), - "upstream authentication resource is unavailable" + "The upstream account cannot access the service." ); } } @@ -550,7 +550,7 @@ fn engine_provider_runtime_failures_should_collapse_to_safe_unavailability() { let projected = gateway_error_from_engine(&error); assert_eq!(projected.kind(), GatewayErrorKind::UpstreamUnavailable); - assert_eq!(projected.safe_message(), "upstream service is unavailable"); + assert_eq!(projected.safe_message(), "Upstream service is unavailable."); } } @@ -560,13 +560,13 @@ fn engine_store_error_should_collapse_to_safe_internal_error() { let projected = gateway_error_from_engine(&error); assert_eq!(projected.kind(), GatewayErrorKind::Internal); - assert_eq!(projected.safe_message(), "request execution failed"); + assert_eq!(projected.safe_message(), "Request execution failed."); } #[test] fn openai_error_response_should_preserve_only_safe_contract_fields() { let (status, body) = openai_error_response( StatusCode::BAD_GATEWAY, - "upstream service is unavailable", + "Upstream service is unavailable.", "server_error", "upstream_unavailable", ); @@ -577,7 +577,7 @@ fn openai_error_response_should_preserve_only_safe_contract_fields() { StatusCode::BAD_GATEWAY, serde_json::json!({ "error": { - "message": "upstream service is unavailable", + "message": "Upstream service is unavailable.", "type": "server_error", "code": "upstream_unavailable" } @@ -767,12 +767,12 @@ mod model_routing { ( "missing-model", BTreeMap::new(), - "the requested model was not found in the provider catalogs available to this API key; check the model name", + "Model unavailable for this API key. Check the model name.", ), ( "model-a", BTreeMap::from([("model-a".to_owned(), "missing-target".to_owned())]), - "the requested model maps to an upstream model that was not found in the provider catalogs available to this API key; check the configured model mapping", + "Mapped model unavailable for this API key. Check the model mapping.", ), ] { for stream in [false, true] { diff --git a/backend/crates/gateway-api/tests/openai/models.rs b/backend/crates/gateway-api/tests/openai/models.rs index b9550dcac..299d9ff8f 100644 --- a/backend/crates/gateway-api/tests/openai/models.rs +++ b/backend/crates/gateway-api/tests/openai/models.rs @@ -446,7 +446,7 @@ async fn models_should_reject_recognized_cli_below_configured_min() { .await .expect("list models response"); - assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("read version rejection body"); @@ -470,7 +470,7 @@ async fn models_should_reject_recognized_cli_with_invalid_version() { .await .expect("list models response"); - assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("read version rejection body"); @@ -495,7 +495,7 @@ async fn models_should_reject_desktop_below_its_configured_min() { .await .expect("list models response"); - assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("read version rejection body"); diff --git a/backend/crates/gateway-api/tests/openai/responses/http.rs b/backend/crates/gateway-api/tests/openai/responses/http.rs index 6ff6a53f3..ce4e576be 100644 --- a/backend/crates/gateway-api/tests/openai/responses/http.rs +++ b/backend/crates/gateway-api/tests/openai/responses/http.rs @@ -2406,7 +2406,7 @@ async fn atomic_uncommitted_upstream_failure_batch_should_be_forwarded_once() { assert_eq!(body.matches("event: response.failed").count(), 1); assert!(body.contains(std::str::from_utf8(&raw_created).expect("created frame"))); assert!(body.contains(std::str::from_utf8(&raw_failure).expect("failure frame"))); - assert!(!body.contains("upstream capacity is temporarily unavailable")); + assert!(!body.contains("Upstream capacity is temporarily unavailable.")); assert!(body.ends_with("data: [DONE]\n\n")); assert_eq!(trace.snapshot(), vec!["next_event", "commit", "next_error"]); } @@ -3000,7 +3000,7 @@ async fn official_core_clients_should_be_version_checked_before_starting_respons assert!(String::from_utf8_lossy(&body).contains("event: response.completed\n")); assert!(trace.snapshot().contains(&"commit")); } else { - assert_eq!(status, StatusCode::UPGRADE_REQUIRED, "{product}/{version}"); + assert_eq!(status, StatusCode::BAD_REQUEST, "{product}/{version}"); let value: Value = serde_json::from_slice(&body).unwrap(); assert_eq!(value["error"]["code"], "client_version_too_old"); assert_eq!(value["error"]["current_version"], version); @@ -3071,7 +3071,7 @@ async fn chatgpt_remote_responses_should_only_skip_missing_desktop_versions() { let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); if let Some(code) = expected_code { let value: Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(status, StatusCode::UPGRADE_REQUIRED, "{name} {version:?}"); + assert_eq!(status, StatusCode::BAD_REQUEST, "{name} {version:?}"); assert_eq!(value["error"]["code"], code); assert!(execution.session.lock().unwrap().is_some()); } else { diff --git a/backend/crates/gateway-api/tests/openai/responses/websocket/forward.rs b/backend/crates/gateway-api/tests/openai/responses/websocket/forward.rs index c295ec644..6594af3de 100644 --- a/backend/crates/gateway-api/tests/openai/responses/websocket/forward.rs +++ b/backend/crates/gateway-api/tests/openai/responses/websocket/forward.rs @@ -266,7 +266,7 @@ async fn non_structured_bodies_use_safe_fallback_without_losing_status_or_ids() assert_eq!( error["error"], json!({ - "message": "upstream service is unavailable", + "message": "Upstream service is unavailable.", "code": "upstream_unavailable", "type": "server_error", }) @@ -365,7 +365,10 @@ async fn provider_failure_facts_survive_without_a_complete_http_response() { "observed-turn-state" ); assert!(error["headers"].get("x-oai-request-id").is_none()); - assert_eq!(error["error"]["message"], "upstream service is unavailable"); + assert_eq!( + error["error"]["message"], + "Upstream service is unavailable." + ); } #[tokio::test] diff --git a/backend/crates/gateway-api/tests/openai/responses/websocket/mod.rs b/backend/crates/gateway-api/tests/openai/responses/websocket/mod.rs index 5e30e2851..dc2ceb6d4 100644 --- a/backend/crates/gateway-api/tests/openai/responses/websocket/mod.rs +++ b/backend/crates/gateway-api/tests/openai/responses/websocket/mod.rs @@ -1241,7 +1241,7 @@ async fn official_core_version_gate_should_run_before_websocket_upgrade() { else { panic!("expected an HTTP version rejection"); }; - assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); let value: Value = serde_json::from_slice(response.body().as_deref().unwrap()).unwrap(); assert_eq!(value["error"]["code"], "client_version_too_old"); assert_eq!(value["error"]["current_version"], "0.158.0"); diff --git a/backend/crates/gateway-core/src/concurrency.rs b/backend/crates/gateway-core/src/concurrency.rs index 188938d61..264ca43fe 100644 --- a/backend/crates/gateway-core/src/concurrency.rs +++ b/backend/crates/gateway-core/src/concurrency.rs @@ -385,11 +385,11 @@ impl QueueRejection { match self { Self::Full => crate::error::GatewayError::new( crate::error::GatewayErrorKind::ConcurrencyQueueFull, - "concurrency wait queue is full", + "Request queue is full.", ), Self::Timeout => crate::error::GatewayError::new( crate::error::GatewayErrorKind::ConcurrencyQueueTimeout, - "concurrency wait deadline elapsed", + "Request queue wait timed out.", ), } } diff --git a/backend/crates/gateway-core/src/engine/coordinator.rs b/backend/crates/gateway-core/src/engine/coordinator.rs index 21ca18482..d2117f778 100644 --- a/backend/crates/gateway-core/src/engine/coordinator.rs +++ b/backend/crates/gateway-core/src/engine/coordinator.rs @@ -1059,7 +1059,7 @@ where let Some(candidate) = self.plan.candidates().get(self.candidate_index).cloned() else { let error = GatewayError::new( GatewayErrorKind::NoAvailableProvider, - "no upstream Provider is available", + "No upstream service is available.", ); self.finish_failure(FailureFinalization { outcome: ExecutionOutcome::Failed, @@ -1365,7 +1365,7 @@ where self.record_provider_failure(candidate.provider().clone()); let error = GatewayError::new( GatewayErrorKind::Internal, - "provider metadata did not match the frozen candidate", + "Provider metadata did not match the frozen candidate.", ); self.finish_failure(FailureFinalization { outcome: ExecutionOutcome::Failed, @@ -1399,7 +1399,7 @@ where drop(stream); let error = GatewayError::new( GatewayErrorKind::Internal, - "provider selected an account outside the frozen client scope", + "Provider selected an account outside the frozen client scope.", ); self.finish_failure(FailureFinalization { outcome: ExecutionOutcome::Failed, @@ -1422,7 +1422,7 @@ where drop(stream); let error = GatewayError::new( GatewayErrorKind::Internal, - "provider did not use the required account", + "Provider did not use the required account.", ); self.finish_failure(FailureFinalization { outcome: ExecutionOutcome::Failed, @@ -1448,7 +1448,7 @@ where drop(stream); let error = GatewayError::new( GatewayErrorKind::Internal, - "native continuation binding did not match selected account", + "Native continuation binding did not match selected account.", ); self.finish_failure(FailureFinalization { outcome: ExecutionOutcome::Failed, @@ -2401,11 +2401,11 @@ where } else { ExecutionOutcome::Failed }, - GatewayError::new(GatewayErrorKind::Timeout, "request deadline elapsed"), + GatewayError::new(GatewayErrorKind::Timeout, "Request deadline elapsed."), ), _ => ( ExecutionOutcome::Failed, - GatewayError::new(GatewayErrorKind::Internal, "request execution failed"), + GatewayError::new(GatewayErrorKind::Internal, "Request execution failed."), ), }; let send_state = if self.attempts == 0 { diff --git a/backend/crates/gateway-core/src/engine/execution/contract.rs b/backend/crates/gateway-core/src/engine/execution/contract.rs index 8cb7ffffa..52c281fcb 100644 --- a/backend/crates/gateway-core/src/engine/execution/contract.rs +++ b/backend/crates/gateway-core/src/engine/execution/contract.rs @@ -196,7 +196,7 @@ impl PreparedRootExecution { .map_err(|_| { GatewayError::new( GatewayErrorKind::InvalidRequest, - "execution settings are invalid", + "Execution settings are invalid.", ) })?; self.client.policy = settings.apply_policy(self.client.policy.clone()); @@ -224,7 +224,7 @@ impl PreparedRootExecution { let started_at = SystemTime::now(); let deadline_at = Deadline::from_timeout(started_at, client.execution_timeout()) .ok_or_else(|| { - GatewayError::new(GatewayErrorKind::Internal, "system clock is invalid") + GatewayError::new(GatewayErrorKind::Internal, "System clock is invalid.") })?; let execution_effects = Arc::new(ExecutionEffects::default()); let execution_effects_baseline = execution_effects.epoch(); @@ -411,7 +411,7 @@ pub trait ExecutionService: Send + Sync { Box::pin(async { Err(GatewayError::new( GatewayErrorKind::Unauthorized, - "plugin model entry is unavailable", + "Plugin model entry is unavailable.", )) }) } @@ -505,6 +505,7 @@ pub(super) fn duration_ms(duration: Duration) -> u64 { } pub(super) fn new_request_id() -> Result { - ModelRequestId::new(format!("req_{}", Uuid::now_v7().simple())) - .map_err(|_| GatewayError::new(GatewayErrorKind::Internal, "failed to allocate request ID")) + ModelRequestId::new(format!("req_{}", Uuid::now_v7().simple())).map_err(|_| { + GatewayError::new(GatewayErrorKind::Internal, "Failed to allocate request ID.") + }) } diff --git a/backend/crates/gateway-core/src/engine/execution/failure.rs b/backend/crates/gateway-core/src/engine/execution/failure.rs index 37149ec17..81daab4b9 100644 --- a/backend/crates/gateway-core/src/engine/execution/failure.rs +++ b/backend/crates/gateway-core/src/engine/execution/failure.rs @@ -9,15 +9,15 @@ use crate::{ pub(super) fn authentication_gateway_error(error: ClientAuthenticationError) -> GatewayError { match error { ClientAuthenticationError::InvalidKey => { - GatewayError::new(GatewayErrorKind::Unauthorized, "client API key is invalid") + GatewayError::new(GatewayErrorKind::Unauthorized, "Client API key is invalid.") } ClientAuthenticationError::SnapshotUnavailable => GatewayError::new( GatewayErrorKind::Internal, - "runtime snapshot is unavailable", + "Gateway configuration is unavailable.", ), ClientAuthenticationError::ProviderUnavailable => GatewayError::new( GatewayErrorKind::Internal, - "frontend authentication provider is unavailable", + "Client authentication service is unavailable.", ), } } @@ -30,24 +30,24 @@ pub(super) fn map_routing_error(error: crate::validation::RoutingError) -> Gatew } => GatewayError::new( GatewayErrorKind::ModelNotFound, if model == mapped_model { - "the requested model was not found in the provider catalogs available to this API key; check the model name" + "Model unavailable for this API key. Check the model name." } else { - "the requested model maps to an upstream model that was not found in the provider catalogs available to this API key; check the configured model mapping" + "Mapped model unavailable for this API key. Check the model mapping." }, ), crate::validation::RoutingError::NoCapableProvider { .. } | crate::validation::RoutingError::NoCapableProviderEndpoint { .. } | crate::validation::RoutingError::EmptyAccountScope => GatewayError::new( GatewayErrorKind::NoAvailableProvider, - "no provider can execute this request", + "No provider can execute this request.", ), crate::validation::RoutingError::UnsupportedProviderEndpoint { .. } => GatewayError::new( GatewayErrorKind::Unsupported, - "the selected provider does not support this operation", + "The selected provider does not support this operation.", ), _ => GatewayError::new( GatewayErrorKind::Internal, - "runtime routing configuration is invalid", + "Runtime routing configuration is invalid.", ), } } @@ -55,23 +55,23 @@ pub(super) fn map_routing_error(error: crate::validation::RoutingError) -> Gatew pub fn gateway_error_from_engine(error: &EngineError) -> GatewayError { match error { EngineError::Cancelled => { - GatewayError::new(GatewayErrorKind::Cancelled, "request was cancelled") + GatewayError::new(GatewayErrorKind::Cancelled, "Request was cancelled.") } EngineError::Deadline => { - GatewayError::new(GatewayErrorKind::Timeout, "request deadline elapsed") + GatewayError::new(GatewayErrorKind::Timeout, "Request deadline elapsed.") } EngineError::Provider(provider) => GatewayError::from_provider(provider), EngineError::EmptyRoutingPlan | EngineError::ProviderNotRegistered { .. } => { GatewayError::new( GatewayErrorKind::NoAvailableProvider, - "no provider is available", + "No provider is available.", ) } EngineError::Store(source) => { - GatewayError::new(GatewayErrorKind::Internal, "request execution failed") + GatewayError::new(GatewayErrorKind::Internal, "Request execution failed.") .with_source(source.clone()) } - _ => GatewayError::new(GatewayErrorKind::Internal, "request execution failed"), + _ => GatewayError::new(GatewayErrorKind::Internal, "Request execution failed."), } } diff --git a/backend/crates/gateway-core/src/engine/execution/scope.rs b/backend/crates/gateway-core/src/engine/execution/scope.rs index d4cc87cc1..e8113e7bd 100644 --- a/backend/crates/gateway-core/src/engine/execution/scope.rs +++ b/backend/crates/gateway-core/src/engine/execution/scope.rs @@ -122,7 +122,7 @@ impl NestedExecutionGraph { { return Err(GatewayError::new( GatewayErrorKind::ConcurrencyQueueFull, - "nested model execution concurrency is exhausted", + "Nested model execution concurrency is exhausted.", )); } if self @@ -135,7 +135,7 @@ impl NestedExecutionGraph { self.active.fetch_sub(1, Ordering::AcqRel); return Err(GatewayError::new( GatewayErrorKind::PolicyDenied, - "nested model execution budget is exhausted", + "Nested model execution budget is exhausted.", )); } Ok(NestedExecutionPermit { diff --git a/backend/crates/gateway-core/src/engine/execution/service.rs b/backend/crates/gateway-core/src/engine/execution/service.rs index ba452557b..8123a01a3 100644 --- a/backend/crates/gateway-core/src/engine/execution/service.rs +++ b/backend/crates/gateway-core/src/engine/execution/service.rs @@ -333,7 +333,7 @@ impl DefaultExecutionService { let authentication = client.authentication.clone().ok_or_else(|| { GatewayError::new( GatewayErrorKind::Internal, - "bound execution identity cannot enter the public start path", + "Bound execution identity cannot enter the public start path.", ) })?; let authentication = match client.settings.clone() { @@ -418,13 +418,13 @@ impl DefaultExecutionService { if authorization.cancellation.is_cancelled() { return Err(GatewayError::new( GatewayErrorKind::Cancelled, - "parent request was cancelled", + "Parent request was cancelled.", )); } if authorization.deadline_at.is_elapsed() { return Err(GatewayError::new( GatewayErrorKind::Timeout, - "request deadline elapsed", + "Request deadline elapsed.", )); } let account_group_ids = authorization @@ -501,7 +501,7 @@ impl DefaultExecutionService { if !authorization.account_scope.provider_kinds().contains(&provider) { return Err(GatewayError::new( GatewayErrorKind::PolicyDenied, - "nested provider is outside the frozen account scope", + "Nested provider is outside the frozen account scope.", )); } routing_context.required_provider = Some(provider); @@ -532,21 +532,21 @@ impl DefaultExecutionService { .map_err(|_| { GatewayError::new( GatewayErrorKind::Internal, - "model routing policy failed", + "Model routing policy failed.", ) })? { ModelRouteDecision::Unhandled => {} ModelRouteDecision::Reject => { return Err(GatewayError::new( GatewayErrorKind::PolicyDenied, - "model routing policy rejected the request", + "Model routing policy rejected the request.", )); } ModelRouteDecision::Route { provider, model } => { if provider.is_none() && model.is_none() { return Err(GatewayError::new( GatewayErrorKind::Internal, - "model routing policy returned an empty target", + "Model routing policy returned an empty target.", )); } if let Some(provider) = provider { @@ -561,7 +561,7 @@ impl DefaultExecutionService { { return Err(GatewayError::new( GatewayErrorKind::PolicyDenied, - "model routing policy exceeded the frozen provider scope", + "Model routing policy exceeded the frozen provider scope.", )); } target_context.required_provider = Some(provider); @@ -601,26 +601,26 @@ impl DefaultExecutionService { Err(error) if error.kind() == NativeContinuationStoreErrorKind::OwnershipMismatch => { - return Err(GatewayError::new(GatewayErrorKind::PolicyDenied, "continuation does not belong to this client API key")); + return Err(GatewayError::new(GatewayErrorKind::PolicyDenied, "Continuation does not belong to this client API key.")); } Err(error) => { tracing::warn!(request_id = request_id.as_str(), %error, "Continuation affinity 记录无效,已拒绝续接"); - return Err(GatewayError::new(GatewayErrorKind::Internal, "continuation state is invalid")); + return Err(GatewayError::new(GatewayErrorKind::Internal, "Continuation state is invalid.")); } }, _ = timeout => None, }; Some(match pin { Some(pin) if !pin.matches_client(request.client.policy.key_id()) => { - return Err(GatewayError::new(GatewayErrorKind::PolicyDenied, "continuation does not belong to this client API key")); + return Err(GatewayError::new(GatewayErrorKind::PolicyDenied, "Continuation does not belong to this client API key.")); } Some(pin) if !authorization.account_scope.allows(pin.account()) => { - return Err(GatewayError::new(GatewayErrorKind::PolicyDenied, "continuation account is outside the client account scope")); + return Err(GatewayError::new(GatewayErrorKind::PolicyDenied, "Continuation account is outside the client account scope.")); } Some(pin) if !plan.candidates().iter().any(|candidate| candidate.provider() == pin.provider()) => { - return Err(GatewayError::new(GatewayErrorKind::NoAvailableProvider, "continuation provider is not available")); + return Err(GatewayError::new(GatewayErrorKind::NoAvailableProvider, "Continuation provider is not available.")); } Some(pin) => { attach_continuation_session_state(&mut request.operation, &pin); @@ -728,7 +728,7 @@ impl DefaultExecutionService { let permit = authorization.nested_permit.take().ok_or_else(|| { GatewayError::new( GatewayErrorKind::Internal, - "nested execution admission is unavailable", + "Nested execution admission is unavailable.", ) })?; (ExecutionAdmission::Nested(permit), None) @@ -953,19 +953,19 @@ impl DefaultExecutionService { .ok_or_else(|| { GatewayError::new( GatewayErrorKind::PolicyDenied, - "parent model request is not active", + "Parent model request is not active.", ) })?; if authority.cancellation.is_cancelled() { return Err(GatewayError::new( GatewayErrorKind::Cancelled, - "parent request was cancelled", + "Parent request was cancelled.", )); } if authority.deadline_at.is_elapsed() { return Err(GatewayError::new( GatewayErrorKind::Timeout, - "parent request deadline elapsed", + "Parent request deadline elapsed.", )); } Ok(authority) @@ -989,7 +989,7 @@ impl DefaultExecutionService { if parent.extension_scope.len() >= ExtensionCallScope::MAXIMUM_DEPTH { return Err(GatewayError::new( GatewayErrorKind::PolicyDenied, - "nested model execution depth is exhausted", + "Nested model execution depth is exhausted.", )); } let extension_scope = parent @@ -998,7 +998,7 @@ impl DefaultExecutionService { .ok_or_else(|| { GatewayError::new( GatewayErrorKind::PolicyDenied, - "recursive plugin model execution is not allowed", + "Recursive plugin model execution is not allowed.", ) })?; let (account_scope, required_provider) = restrict_model_execution_scope( @@ -1057,7 +1057,7 @@ impl DefaultExecutionService { refresh.refresh().await.map_err(|_| { GatewayError::new( GatewayErrorKind::Internal, - "current runtime snapshot is unavailable", + "Gateway configuration is unavailable.", ) })?; } @@ -1068,7 +1068,7 @@ impl DefaultExecutionService { .map_err(|_| { GatewayError::new( GatewayErrorKind::Internal, - "current runtime snapshot is unavailable", + "Gateway configuration is unavailable.", ) })?; let policy = snapshot @@ -1077,11 +1077,14 @@ impl DefaultExecutionService { .ok_or_else(|| { GatewayError::new( GatewayErrorKind::Unauthorized, - "client API key no longer exists", + "Client API key no longer exists.", ) })?; policy.authorize().map_err(|_| { - GatewayError::new(GatewayErrorKind::PolicyDenied, "client API key is disabled") + GatewayError::new( + GatewayErrorKind::PolicyDenied, + "Client API key is disabled.", + ) })?; Ok(AuthenticatedClient { policy: match &settings { @@ -1112,13 +1115,13 @@ impl DefaultExecutionService { if cancellation.is_cancelled() { return Err(GatewayError::new( GatewayErrorKind::Cancelled, - "bound plugin invocation was cancelled", + "Bound plugin invocation was cancelled.", )); } if extension_scope.len() >= ExtensionCallScope::MAXIMUM_DEPTH { return Err(GatewayError::new( GatewayErrorKind::PolicyDenied, - "plugin model execution depth exceeded", + "Plugin model execution depth exceeded.", )); } let extension_scope = extension_scope @@ -1126,13 +1129,13 @@ impl DefaultExecutionService { .ok_or_else(|| { GatewayError::new( GatewayErrorKind::PolicyDenied, - "plugin model execution scope is recursive", + "Plugin model execution scope is recursive.", ) })?; let now = SystemTime::now(); let deadline_at = Deadline::from_timeout(now, client.execution_timeout()).ok_or_else(|| { - GatewayError::new(GatewayErrorKind::Internal, "system clock is invalid") + GatewayError::new(GatewayErrorKind::Internal, "System clock is invalid.") })?; let (account_scope, required_provider) = restrict_model_execution_scope( client.policy.account_scope().as_ref(), @@ -1194,13 +1197,13 @@ impl DefaultExecutionService { if !parent.account_scope.provider_kinds().contains(&provider) { return Err(GatewayError::new( GatewayErrorKind::PolicyDenied, - "affinity provider is outside the frozen authorization scope", + "Affinity provider is outside the frozen authorization scope.", )); } let affinity = self.session_affinity.as_ref().ok_or_else(|| { GatewayError::new( GatewayErrorKind::PolicyDenied, - "provider session affinity is unavailable", + "Provider session affinity is unavailable.", ) })?; let account = { @@ -1211,15 +1214,15 @@ impl DefaultExecutionService { result = load => result.map_err(|error| match error.kind() { ProviderStoreErrorKind::Unavailable => GatewayError::new( GatewayErrorKind::ProviderInfrastructureUnavailable, - "provider session affinity is temporarily unavailable", + "Provider session affinity is temporarily unavailable.", ), ProviderStoreErrorKind::InvalidData | ProviderStoreErrorKind::Conflict => { - GatewayError::new(GatewayErrorKind::Internal, "provider session affinity is invalid") + GatewayError::new(GatewayErrorKind::Internal, "Provider session affinity is invalid.") } })?, _ = timeout => return Err(GatewayError::new( GatewayErrorKind::ProviderInfrastructureUnavailable, - "provider session affinity lookup timed out", + "Provider session affinity lookup timed out.", )), } }; @@ -1261,7 +1264,7 @@ impl DefaultExecutionService { if remaining.is_zero() { return Err(GatewayError::new( GatewayErrorKind::Timeout, - "request deadline elapsed", + "Request deadline elapsed.", )); } // 在发送原子准入之前接管取消清理,覆盖 Redis 已取得租约但返回尚未被观察的窗口 @@ -1280,9 +1283,9 @@ impl DefaultExecutionService { let cancelled = cancellation.cancelled().fuse(); pin_mut!(acquire, timeout, cancelled); let decision = select_biased! { - () = cancelled => return Err(GatewayError::new(GatewayErrorKind::Cancelled, "request admission was cancelled")), - result = acquire => result.map_err(|source| GatewayError::new(GatewayErrorKind::NoAvailableProvider, "request admission is temporarily unavailable").with_source(source))?, - _ = timeout => return Err(GatewayError::new(GatewayErrorKind::Timeout, "request deadline elapsed")), + () = cancelled => return Err(GatewayError::new(GatewayErrorKind::Cancelled, "Request admission was cancelled.")), + result = acquire => result.map_err(|source| GatewayError::new(GatewayErrorKind::NoAvailableProvider, "Request admission is temporarily unavailable.").with_source(source))?, + _ = timeout => return Err(GatewayError::new(GatewayErrorKind::Timeout, "Request deadline elapsed.")), }; match decision { ClientAdmissionDecision::Granted => { @@ -1307,7 +1310,7 @@ impl DefaultExecutionService { if reason == ClientAdmissionRejection::RateLimited || policy.max_waiting == 0 { return Err(GatewayError::new( GatewayErrorKind::RateLimited, - "request exceeds client API key limits", + "Request exceeds client API key limits.", )); } if waiting.elapsed().is_zero() @@ -1345,12 +1348,12 @@ impl DefaultExecutionService { .map_err(|_| { GatewayError::new( GatewayErrorKind::Internal, - "runtime snapshot is unavailable", + "Gateway configuration is unavailable.", ) })?; let public_model = PublicModelId::new(upstream_model.as_str().to_owned()).map_err(|_| { - GatewayError::new(GatewayErrorKind::Unsupported, "requested model is invalid") + GatewayError::new(GatewayErrorKind::Unsupported, "Requested model is invalid.") })?; let routing_context = RoutingContext { required_provider: Some(provider_kind), @@ -1361,11 +1364,11 @@ impl DefaultExecutionService { .map_err(map_routing_error)?; let started_at = SystemTime::now(); let deadline_at = started_at.checked_add(DIAGNOSTIC_TIMEOUT).ok_or_else(|| { - GatewayError::new(GatewayErrorKind::Internal, "system clock is invalid") + GatewayError::new(GatewayErrorKind::Internal, "System clock is invalid.") })?; let request_id = new_request_id()?; let actor = ClientApiKeyId::new("admin_connection_test") - .map_err(|_| GatewayError::new(GatewayErrorKind::Internal, "invalid admin actor"))?; + .map_err(|_| GatewayError::new(GatewayErrorKind::Internal, "Invalid admin actor."))?; let new_request = NewModelRequest { id: request_id, client_api_key_name_snapshot: None, @@ -1742,7 +1745,7 @@ impl ClientIdentityPort for DefaultExecutionService { let cancelled = cancellation.cancelled().fuse(); pin_mut!(cancelled); select_biased! { - () = cancelled => Err(GatewayError::new(GatewayErrorKind::Cancelled, "client verification was cancelled")), + () = cancelled => Err(GatewayError::new(GatewayErrorKind::Cancelled, "Client verification was cancelled.")), result = verification => result.map_err(authentication_gateway_error), } } @@ -1768,9 +1771,9 @@ impl NestedModelExecutionPort for DefaultExecutionService { .fuse(); pin_mut!(cancelled, timeout, catalog); let models = select_biased! { - () = cancelled => return Err(GatewayError::new(GatewayErrorKind::Cancelled, "model catalog call was cancelled")), - () = timeout => return Err(GatewayError::new(GatewayErrorKind::Timeout, "model catalog deadline elapsed")), - result = catalog => result.map_err(|_| GatewayError::new(GatewayErrorKind::Internal, "model catalog is unavailable"))?, + () = cancelled => return Err(GatewayError::new(GatewayErrorKind::Cancelled, "Model catalog call was cancelled.")), + () = timeout => return Err(GatewayError::new(GatewayErrorKind::Timeout, "Model catalog deadline elapsed.")), + result = catalog => result.map_err(|_| GatewayError::new(GatewayErrorKind::Internal, "Model catalog is unavailable."))?, }; Ok(models .into_iter() @@ -1850,13 +1853,13 @@ fn restrict_model_execution_scope( if !parent_scope.allows(parent_account) { return Err(GatewayError::new( GatewayErrorKind::PolicyDenied, - "parent attempt account is outside the frozen account scope", + "Parent attempt account is outside the frozen account scope.", )); } if account == Some(parent_account) { return Err(GatewayError::new( GatewayErrorKind::PolicyDenied, - "nested execution cannot wait for the parent attempt account", + "Nested execution cannot wait for the parent attempt account.", )); } account_scope = account_scope.excluding_account(parent_account.clone()); @@ -1866,7 +1869,7 @@ fn restrict_model_execution_scope( if !account_scope.allows(account) { return Err(GatewayError::new( GatewayErrorKind::PolicyDenied, - "model account is outside the frozen authorization scope", + "Model account is outside the frozen authorization scope.", )); } let account_provider = account_scope @@ -1875,7 +1878,7 @@ fn restrict_model_execution_scope( .ok_or_else(|| { GatewayError::new( GatewayErrorKind::PolicyDenied, - "model account is unavailable in the frozen snapshot", + "Model account is unavailable in the frozen snapshot.", ) })?; if provider @@ -1884,7 +1887,7 @@ fn restrict_model_execution_scope( { return Err(GatewayError::new( GatewayErrorKind::PolicyDenied, - "model provider and account do not match", + "Model provider and account do not match.", )); } Some(account_provider) @@ -1898,7 +1901,7 @@ fn restrict_model_execution_scope( { return Err(GatewayError::new( GatewayErrorKind::PolicyDenied, - "model provider is outside the frozen authorization scope", + "Model provider is outside the frozen authorization scope.", )); } Ok((Arc::new(account_scope), required_provider)) diff --git a/backend/crates/gateway-core/src/engine/image_generation.rs b/backend/crates/gateway-core/src/engine/image_generation.rs index 7875af90d..39f767067 100644 --- a/backend/crates/gateway-core/src/engine/image_generation.rs +++ b/backend/crates/gateway-core/src/engine/image_generation.rs @@ -12,7 +12,7 @@ pub(super) fn check_image_generation( if !allowed && operation.image_generation_requested() { return Err(GatewayError::new( GatewayErrorKind::PolicyDenied, - "Image generation is not allowed by the API key's account groups", + "Image generation is not allowed by the API key's account groups.", ) .with_client_code("image_generation_disabled")); } diff --git a/backend/crates/gateway-core/src/error/mod.rs b/backend/crates/gateway-core/src/error/mod.rs index 71d757222..af2fc0a04 100644 --- a/backend/crates/gateway-core/src/error/mod.rs +++ b/backend/crates/gateway-core/src/error/mod.rs @@ -1203,62 +1203,63 @@ impl GatewayError { #[must_use] pub fn from_provider(error: &ProviderError) -> Self { let gateway = match error.kind() { - ProviderErrorKind::InvalidRequest => { - Self::new(GatewayErrorKind::InvalidRequest, "invalid upstream request") - } + ProviderErrorKind::InvalidRequest => Self::new( + GatewayErrorKind::InvalidRequest, + "Invalid upstream request.", + ), ProviderErrorKind::SessionPolicyRestricted => Self::new( GatewayErrorKind::InvalidRequest, - "session policy excludes all eligible accounts", + "No accounts are available under this conversation's restrictions.", ), ProviderErrorKind::ContinuationRecoveryRequired => Self::new( GatewayErrorKind::InvalidRequest, - "conversation continuation must be rebuilt", + "Previous response unavailable. Resend the full conversation history.", ), ProviderErrorKind::Unsupported => Self::new( GatewayErrorKind::Unsupported, - "requested capability is unsupported", + "Requested capability is unsupported.", ), ProviderErrorKind::Unauthorized | ProviderErrorKind::PermissionDenied => Self::new( GatewayErrorKind::UpstreamUnavailable, - "upstream authentication resource is unavailable", + "The upstream account cannot access the service.", ), ProviderErrorKind::RequestPolicyDenied => Self::new( GatewayErrorKind::PolicyDenied, - "request policy rejected the request", + "Request policy rejected the request.", ), ProviderErrorKind::RateLimited | ProviderErrorKind::QuotaExhausted => Self::new( GatewayErrorKind::RateLimited, - "upstream capacity is temporarily unavailable", + "Upstream capacity is temporarily unavailable.", ), ProviderErrorKind::ConcurrencyQueueFull => Self::new( GatewayErrorKind::ConcurrencyQueueFull, - "concurrency wait queue is full", + "Request queue is full.", ), ProviderErrorKind::ConcurrencyQueueTimeout => Self::new( GatewayErrorKind::ConcurrencyQueueTimeout, - "concurrency wait deadline elapsed", + "Request queue wait timed out.", ), ProviderErrorKind::AccountCapacityUnavailable => Self::new( GatewayErrorKind::AccountCapacityUnavailable, - "all eligible upstream accounts are temporarily busy", + "All eligible upstream accounts are temporarily busy.", ), ProviderErrorKind::NoEligibleAccount => Self::new( GatewayErrorKind::NoAvailableProvider, - "no upstream provider is currently available for this request", + "No upstream provider is currently available for this request.", ), ProviderErrorKind::ProviderInfrastructureUnavailable => Self::new( GatewayErrorKind::ProviderInfrastructureUnavailable, - "provider account infrastructure is temporarily unavailable", + "Upstream accounts are temporarily inaccessible.", ), ProviderErrorKind::Timeout => { - Self::new(GatewayErrorKind::Timeout, "upstream request timed out") + Self::new(GatewayErrorKind::Timeout, "Upstream request timed out.") } ProviderErrorKind::MessageTooBig => Self::new( GatewayErrorKind::MessageTooBig, - "upstream rejected the request because the message is too large", + "Request too large. Reduce the input size.", ), ProviderErrorKind::Cancelled => { - Self::new(GatewayErrorKind::Cancelled, "request was cancelled") + Self::new(GatewayErrorKind::Cancelled, "Request was cancelled.") } ProviderErrorKind::Transport | ProviderErrorKind::Protocol @@ -1266,7 +1267,7 @@ impl GatewayError { | ProviderErrorKind::UpstreamCapacityUnavailable | ProviderErrorKind::ProcessTerminated => Self::new( GatewayErrorKind::UpstreamUnavailable, - "upstream service is unavailable", + "Upstream service is unavailable.", ), }; let gateway = match error.diagnostic() { diff --git a/backend/crates/gateway-core/src/lifecycle.rs b/backend/crates/gateway-core/src/lifecycle.rs index 057e8e6b1..df0572048 100644 --- a/backend/crates/gateway-core/src/lifecycle.rs +++ b/backend/crates/gateway-core/src/lifecycle.rs @@ -37,26 +37,26 @@ impl CancellationReason { #[must_use] pub const fn message(self) -> &'static str { match self { - Self::Requested => "request was cancelled", + Self::Requested => "Request was cancelled.", Self::Lease { kind: RequestLeaseKind::ClientAdmission, failure, } => match failure { - LeaseFailure::Lost => "request was cancelled: client admission lease was lost", + LeaseFailure::Lost => "Request was cancelled: client admission lease was lost.", LeaseFailure::RenewalTimedOut => { - "request was cancelled: client admission lease renewal timed out" + "Request was cancelled: client admission lease renewal timed out." } - LeaseFailure::Expired => "request was cancelled: client admission lease expired", + LeaseFailure::Expired => "Request was cancelled: client admission lease expired.", }, Self::Lease { kind: RequestLeaseKind::Credential, failure, } => match failure { - LeaseFailure::Lost => "request was cancelled: credential lease was lost", + LeaseFailure::Lost => "Request was cancelled: credential lease was lost.", LeaseFailure::RenewalTimedOut => { - "request was cancelled: credential lease renewal timed out" + "Request was cancelled: credential lease renewal timed out." } - LeaseFailure::Expired => "request was cancelled: credential lease expired", + LeaseFailure::Expired => "Request was cancelled: credential lease expired.", }, } } diff --git a/backend/crates/gateway-core/tests/engine/execution/mod.rs b/backend/crates/gateway-core/tests/engine/execution/mod.rs index a20efb6dc..dcffde6e4 100644 --- a/backend/crates/gateway-core/tests/engine/execution/mod.rs +++ b/backend/crates/gateway-core/tests/engine/execution/mod.rs @@ -3776,7 +3776,7 @@ fn known_catalog_should_reject_a_model_that_the_provider_did_not_publish() { (error.kind(), error.client_message()), ( GatewayErrorKind::ModelNotFound, - "the requested model was not found in the provider catalogs available to this API key; check the model name", + "Model unavailable for this API key. Check the model name.", ) ); let rejections = store.entry_rejections.lock().unwrap(); diff --git a/backend/crates/gateway-core/tests/error/mod.rs b/backend/crates/gateway-core/tests/error/mod.rs index a264b4729..3ddc5f71d 100644 --- a/backend/crates/gateway-core/tests/error/mod.rs +++ b/backend/crates/gateway-core/tests/error/mod.rs @@ -280,7 +280,7 @@ fn gateway_error_should_keep_client_visible_upstream_fields_out_of_safe_diagnost assert_eq!( gateway.safe_message(), - "upstream capacity is temporarily unavailable" + "Upstream capacity is temporarily unavailable." ); assert_eq!(gateway.client_message(), message); assert_eq!(gateway.client_error_code(), Some("quota_exhausted")); @@ -304,7 +304,7 @@ fn continuation_recovery_should_keep_its_internal_classification_and_client_cont assert_eq!(error.kind().as_str(), "continuation_recovery_required"); assert_eq!( gateway.safe_message(), - "conversation continuation must be rebuilt" + "Previous response unavailable. Resend the full conversation history." ); assert_eq!( gateway.client_error_code(), @@ -329,7 +329,7 @@ fn message_too_big_should_map_to_a_request_scoped_client_contract() { assert_eq!(gateway.kind().as_str(), "message_too_big"); assert_eq!( gateway.safe_message(), - "upstream rejected the request because the message is too large" + "Request too large. Reduce the input size." ); assert_eq!(gateway.client_error_code(), Some("message_too_big")); assert_eq!(gateway.client_error_type(), Some("invalid_request_error")); diff --git a/backend/crates/gateway-host/src/lib.rs b/backend/crates/gateway-host/src/lib.rs index 8dd5b70d1..698be7fd3 100644 --- a/backend/crates/gateway-host/src/lib.rs +++ b/backend/crates/gateway-host/src/lib.rs @@ -160,6 +160,7 @@ impl HostBundle { lease: Arc, diagnostics: Arc, ) -> Result<(), HostError> { + self.system.set_diagnostics(Arc::clone(&diagnostics)); self.workers.start(plan, lease, diagnostics)?; Ok(()) } diff --git a/backend/crates/gateway-host/src/system_update/archive.rs b/backend/crates/gateway-host/src/system_update/archive.rs index 8f10689e4..8841fa7e5 100644 --- a/backend/crates/gateway-host/src/system_update/archive.rs +++ b/backend/crates/gateway-host/src/system_update/archive.rs @@ -22,7 +22,7 @@ pub(crate) fn extract_release( temp_dir: &Path, ) -> Result { let file = fs::File::open(archive_path) - .map_err(|error| internal(format!("failed to open release archive: {error}")))?; + .map_err(|error| internal("无法打开更新包").with_source(error))?; let decoder = flate2::read::GzDecoder::new(file); let mut archive = tar::Archive::new(decoder); let binary_path = temp_dir.join(APP_BINARY_NAME); @@ -37,61 +37,55 @@ pub(crate) fn extract_release( for entry in archive .entries() - .map_err(|error| internal(format!("failed to read release archive: {error}")))? + .map_err(|error| internal("无法读取更新包").with_source(error))? { let mut entry = - entry.map_err(|error| internal(format!("invalid archive entry: {error}")))?; + entry.map_err(|error| internal("更新包内容损坏,请重新下载").with_source(error))?; let path = entry .path() - .map_err(|error| internal(format!("invalid archive path: {error}")))? + .map_err(|error| internal("更新包中的文件路径无效").with_source(error))? .into_owned(); if unsafe_archive_path(&path) { - return Err(invalid("release archive contains an unsafe path")); + return Err(invalid("更新包包含不安全的文件路径,已停止更新")); } let official_relative = official_plugin_relative_path(&path)?; if !entry.header().entry_type().is_file() { if official_relative.is_some() { - return Err(invalid( - "release archive contains a non-file official plugin asset", - )); + return Err(invalid("更新包中的官方插件资源不是普通文件")); } continue; } file_count = file_count.saturating_add(1); extracted_size = extracted_size.saturating_add(entry.header().size().unwrap_or(u64::MAX)); if file_count > MAX_ARCHIVE_FILES || extracted_size > MAX_EXTRACTED_SIZE { - return Err(invalid("release archive expands beyond safety limits")); + return Err(invalid("更新包解压后的大小或文件数量超出限制")); } if path.file_name().is_some_and(|name| name == APP_BINARY_NAME) { if found_binary { - return Err(invalid("release archive contains duplicate binaries")); + return Err(invalid("更新包包含重复的程序文件")); } entry .unpack(&binary_path) - .map_err(|error| internal(format!("failed to extract binary: {error}")))?; + .map_err(|error| internal("无法解压程序文件").with_source(error))?; found_binary = true; continue; } if let Some(relative) = official_relative { if !official_files.insert(relative.clone()) { - return Err(invalid( - "release archive contains a duplicate official plugin asset", - )); + return Err(invalid("更新包包含重复的官方插件文件")); } - fs::create_dir_all(&official_plugins_dir).map_err(|error| { - internal(format!("failed to create official plugin dir: {error}")) - })?; + fs::create_dir_all(&official_plugins_dir) + .map_err(|error| internal("无法创建官方插件目录").with_source(error))?; let target = official_plugins_dir.join(&relative); - entry.unpack(&target).map_err(|error| { - internal(format!("failed to extract official plugin asset: {error}")) - })?; + entry + .unpack(&target) + .map_err(|error| internal("无法解压官方插件文件").with_source(error))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; - fs::set_permissions(&target, fs::Permissions::from_mode(0o444)).map_err( - |error| internal(format!("failed to protect official plugin asset: {error}")), - )?; + fs::set_permissions(&target, fs::Permissions::from_mode(0o444)) + .map_err(|error| internal("无法设置官方插件文件权限").with_source(error))?; } found_official_manifest |= relative == Path::new("plugin-release-manifest.json"); continue; @@ -102,29 +96,26 @@ pub(crate) fn extract_release( } let target = web_dist_dir.join(relative); if let Some(parent) = target.parent() { - fs::create_dir_all(parent).map_err(|error| { - internal(format!("failed to create web asset dir: {error}")) - })?; + fs::create_dir_all(parent) + .map_err(|error| internal("无法创建管理页面目录").with_source(error))?; } entry .unpack(&target) - .map_err(|error| internal(format!("failed to extract web asset: {error}")))?; + .map_err(|error| internal("无法解压管理页面文件").with_source(error))?; found_web = true; } } if !found_binary { - return Err(invalid("release archive does not contain codex-proxy-rs")); + return Err(invalid("更新包缺少网关程序文件")); } if !found_official_manifest { - return Err(invalid( - "release archive does not contain the official plugin manifest", - )); + return Err(invalid("更新包缺少官方插件发行清单")); } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; fs::set_permissions(&binary_path, fs::Permissions::from_mode(0o755)) - .map_err(|error| internal(format!("failed to chmod binary: {error}")))?; + .map_err(|error| internal("无法设置程序执行权限").with_source(error))?; } Ok(ExtractedRelease { binary_path, @@ -187,9 +178,7 @@ fn official_plugin_relative_path(path: &Path) -> Result, Operati return Ok(None); } if relative.len() != 1 { - return Err(invalid( - "release archive contains a nested official plugin asset", - )); + return Err(invalid("更新包中的官方插件目录结构不符合要求")); } let file_name = relative[0]; let is_manifest = file_name == "plugin-release-manifest.json"; @@ -197,9 +186,7 @@ fn official_plugin_relative_path(path: &Path) -> Result, Operati .to_str() .is_some_and(|value| value.ends_with(".tar.gz")); if !is_manifest && !is_archive { - return Err(invalid( - "release archive contains an unexpected official plugin asset", - )); + return Err(invalid("更新包包含不支持的官方插件文件类型")); } return Ok(Some(PathBuf::from(file_name))); } diff --git a/backend/crates/gateway-host/src/system_update/config.rs b/backend/crates/gateway-host/src/system_update/config.rs index 65fcb6cef..e0d7f5db3 100644 --- a/backend/crates/gateway-host/src/system_update/config.rs +++ b/backend/crates/gateway-host/src/system_update/config.rs @@ -130,7 +130,7 @@ impl SystemUpdateConfig { } env::current_exe() .and_then(fs::canonicalize) - .map_err(|error| internal(format!("failed to resolve executable: {error}"))) + .map_err(|error| internal("无法确定当前程序位置").with_source(error)) } pub(crate) fn official_plugins_dir(&self) -> Result { @@ -138,7 +138,7 @@ impl SystemUpdateConfig { let parent = executable .parent() .filter(|path| !path.as_os_str().is_empty()) - .ok_or_else(|| internal("failed to resolve official plugin release directory"))?; + .ok_or_else(|| internal("无法确定官方插件安装目录"))?; Ok(parent.join("plugins").join("official")) } @@ -146,7 +146,7 @@ impl SystemUpdateConfig { self.web_dist_dir .as_deref() .filter(|path| !path.as_os_str().is_empty()) - .ok_or_else(|| conflict("web assets directory is not configured")) + .ok_or_else(|| conflict("尚未配置管理页面文件目录,无法在线更新")) } } diff --git a/backend/crates/gateway-host/src/system_update/diagnostics.rs b/backend/crates/gateway-host/src/system_update/diagnostics.rs new file mode 100644 index 000000000..3aef35b82 --- /dev/null +++ b/backend/crates/gateway-host/src/system_update/diagnostics.rs @@ -0,0 +1,57 @@ +//! 更新 owner 消费失败时记录受控详情,初始化和关闭阶段保持尽力写入 + +use std::sync::{Arc, OnceLock}; + +use gateway_core::diagnostics::{OperationalDiagnostics, OperationalFailure}; + +use super::OperationError; + +#[derive(Clone, Default)] +pub(super) struct Diagnostics(Arc>>); + +impl Diagnostics { + pub(super) fn bind(&self, diagnostics: Arc) { + let _ = self.0.set(diagnostics); + } + + pub(super) async fn record( + &self, + operation: &'static str, + correlation_id: Option<&str>, + error: &OperationError, + ) { + let Some(diagnostics) = self.0.get() else { + return; + }; + let mut failure = OperationalFailure::new( + "system_update", + operation, + "system_operation_failed", + error.message(), + ); + failure.correlation_id = correlation_id.map(str::to_owned); + failure.details = error.error_details(); + if diagnostics.record_failure(failure).await.is_err() { + tracing::warn!(operation, "系统操作诊断记录失败"); + } + } + + pub(super) fn record_on_drop( + &self, + operation: &'static str, + correlation_id: Option, + error: OperationError, + ) { + // 析构不能等待异步存储,运行时已关闭时沿用诊断尽力写入的合同 + if self.0.get().is_some() + && let Ok(runtime) = tokio::runtime::Handle::try_current() + { + let diagnostics = self.clone(); + drop(runtime.spawn(async move { + diagnostics + .record(operation, correlation_id.as_deref(), &error) + .await; + })); + } + } +} diff --git a/backend/crates/gateway-host/src/system_update/download.rs b/backend/crates/gateway-host/src/system_update/download.rs index bd7685304..42ed56aa7 100644 --- a/backend/crates/gateway-host/src/system_update/download.rs +++ b/backend/crates/gateway-host/src/system_update/download.rs @@ -25,7 +25,7 @@ pub(crate) async fn download_file( ) -> Result<(), OperationError> { validate_download_url(url, api_base)?; if expected_size == 0 || expected_size > MAX_DOWNLOAD_SIZE { - return Err(invalid("release archive size is invalid")); + return Err(invalid("更新包大小无效或超出限制")); } let client = download_client(api_base, Duration::from_secs(120))?; let response = client @@ -33,32 +33,32 @@ pub(crate) async fn download_file( .header(reqwest::header::ACCEPT_ENCODING, "identity") .send() .await - .map_err(|error| upstream(format!("release download failed: {error}")))?; + .map_err(|error| super::error::http("更新包下载失败", error))?; if !response.status().is_success() { return Err(upstream(format!( - "release download failed with {}", - response.status() + "更新包下载失败(HTTP {})", + response.status().as_u16() ))); } if response .content_length() .is_some_and(|size| size != expected_size) { - return Err(upstream("downloaded size does not match release metadata")); + return Err(upstream("下载文件大小与发行信息不一致")); } let mut file = fs::File::create(destination) - .map_err(|error| super::internal(format!("failed to create download file: {error}")))?; + .map_err(|error| super::internal("无法创建下载文件").with_source(error))?; let mut stream = response.bytes_stream(); let mut downloaded = 0_u64; let mut next_progress = 10_u8; while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|error| upstream(format!("download stream failed: {error}")))?; + let chunk = chunk.map_err(|error| super::error::http("更新包下载中断", error))?; downloaded = downloaded.saturating_add(u64::try_from(chunk.len()).unwrap_or(u64::MAX)); if downloaded > expected_size { - return Err(invalid("download exceeds declared release size")); + return Err(invalid("下载文件超过发行信息声明的大小,已停止更新")); } file.write_all(&chunk) - .map_err(|error| super::internal(format!("failed to write download: {error}")))?; + .map_err(|error| super::internal("无法保存下载内容").with_source(error))?; let percent = download_percent(downloaded, expected_size); if percent >= next_progress || downloaded == expected_size { events.emit_progress( @@ -74,9 +74,9 @@ pub(crate) async fn download_file( } } file.sync_all() - .map_err(|error| super::internal(format!("failed to sync download: {error}")))?; + .map_err(|error| super::internal("无法完成下载文件写入").with_source(error))?; if downloaded != expected_size { - return Err(upstream("downloaded size does not match release metadata")); + return Err(upstream("下载文件大小与发行信息不一致")); } Ok(()) } @@ -90,7 +90,7 @@ pub(crate) async fn verify_checksum( ) -> Result<(), OperationError> { validate_download_url(checksum_url, api_base)?; if checksum_size == 0 || checksum_size > MAX_CHECKSUM_SIZE { - return Err(invalid("release checksum size is invalid")); + return Err(invalid("校验文件大小无效或超出限制")); } let client = download_client(api_base, Duration::from_secs(30))?; let response = client @@ -98,27 +98,27 @@ pub(crate) async fn verify_checksum( .header(reqwest::header::ACCEPT_ENCODING, "identity") .send() .await - .map_err(|error| upstream(format!("checksum download failed: {error}")))?; + .map_err(|error| super::error::http("校验文件下载失败", error))?; if !response.status().is_success() { return Err(upstream(format!( - "checksum download failed with {}", - response.status() + "校验文件下载失败(HTTP {})", + response.status().as_u16() ))); } let bytes = response .bytes() .await - .map_err(|error| upstream(format!("checksum read failed: {error}")))?; + .map_err(|error| super::error::http("校验文件读取失败", error))?; if bytes.len() > MAX_CHECKSUM_SIZE as usize || bytes.len() as u64 != checksum_size { - return Err(invalid("checksum document size is invalid")); + return Err(invalid("校验文件大小与发行信息不一致")); } let body = - std::str::from_utf8(&bytes).map_err(|_| invalid("checksum document is not UTF-8"))?; + std::str::from_utf8(&bytes).map_err(|_| invalid("校验文件编码无效,无法校验更新包"))?; let expected = checksum_for(body, file_name) - .ok_or_else(|| upstream("release checksum entry is missing"))?; + .ok_or_else(|| upstream("校验文件中缺少当前更新包的校验值"))?; let actual = sha256_file(file_path)?; if !expected.eq_ignore_ascii_case(&actual) { - return Err(upstream("release checksum mismatch")); + return Err(upstream("更新包校验失败,请重新下载")); } Ok(()) } @@ -137,13 +137,13 @@ fn checksum_for(body: &str, file_name: &str) -> Option { fn sha256_file(path: &Path) -> Result { let mut file = fs::File::open(path) - .map_err(|error| super::internal(format!("failed to open checksum file: {error}")))?; + .map_err(|error| super::internal("无法打开待校验文件").with_source(error))?; let mut hasher = sha2::Sha256::new(); let mut buffer = [0_u8; 64 * 1024]; loop { let read = file .read(&mut buffer) - .map_err(|error| super::internal(format!("failed to read checksum file: {error}")))?; + .map_err(|error| super::internal("无法读取待校验文件").with_source(error))?; if read == 0 { break; } diff --git a/backend/crates/gateway-host/src/system_update/error.rs b/backend/crates/gateway-host/src/system_update/error.rs index bdf7d85bb..3463535bd 100644 --- a/backend/crates/gateway-host/src/system_update/error.rs +++ b/backend/crates/gateway-host/src/system_update/error.rs @@ -16,6 +16,16 @@ pub(super) fn upstream(message: impl Into) -> OperationError { SystemOperationError::new(SystemOperationErrorKind::Upstream, message) } +pub(super) fn http(message: &'static str, source: reqwest::Error) -> OperationError { + let failure = upstream(message); + let failure = if source.url().is_some() { + failure.redact_sensitive_context() + } else { + failure + }; + failure.with_source(source.without_url()) +} + pub(super) fn internal(message: impl Into) -> OperationError { SystemOperationError::new(SystemOperationErrorKind::Internal, message) } diff --git a/backend/crates/gateway-host/src/system_update/installation.rs b/backend/crates/gateway-host/src/system_update/installation.rs index 8b107548c..30d6c0140 100644 --- a/backend/crates/gateway-host/src/system_update/installation.rs +++ b/backend/crates/gateway-host/src/system_update/installation.rs @@ -52,8 +52,7 @@ impl ReleaseFiles { } fn read_error(error: io::Error) -> OperationError { - tracing::warn!(error = %error, "核对系统安装文件失败"); - internal("无法核对安装文件,请检查文件完整性与读取权限") + internal("无法核对安装文件").with_source(error) } fn hash_directory(path: &Path) -> io::Result { diff --git a/backend/crates/gateway-host/src/system_update/mod.rs b/backend/crates/gateway-host/src/system_update/mod.rs index 81f3968c1..b0af8e730 100644 --- a/backend/crates/gateway-host/src/system_update/mod.rs +++ b/backend/crates/gateway-host/src/system_update/mod.rs @@ -2,6 +2,7 @@ mod archive; mod config; +mod diagnostics; mod download; mod error; mod events; diff --git a/backend/crates/gateway-host/src/system_update/operations.rs b/backend/crates/gateway-host/src/system_update/operations.rs index b7411a641..99ea3c62d 100644 --- a/backend/crates/gateway-host/src/system_update/operations.rs +++ b/backend/crates/gateway-host/src/system_update/operations.rs @@ -44,6 +44,7 @@ pub struct ProcessSystemOperations { operation_lock: Arc>>, release_cache: Arc, running_files: Arc>, + diagnostics: super::diagnostics::Diagnostics, } impl ProcessSystemOperations { @@ -62,13 +63,22 @@ impl ProcessSystemOperations { operation_lock: Arc::new(AsyncMutex::new(None)), release_cache: Arc::new(ReleaseCache::default()), running_files, + diagnostics: super::diagnostics::Diagnostics::default(), } } + /// Store 就绪后绑定受控诊断端口,不改变 Host 的初始化顺序 + pub fn set_diagnostics( + &self, + diagnostics: Arc, + ) { + self.diagnostics.bind(diagnostics); + } + fn lock_operation(&self) -> Result>, OperationError> { let operation = Arc::clone(&self.operation_lock) .try_lock_owned() - .map_err(|_| conflict("system operation is already running"))?; + .map_err(|_| conflict("已有系统操作正在执行"))?; if operation.is_some() { return Err(conflict("服务正在重启")); } @@ -95,7 +105,7 @@ impl ProcessSystemOperations { let selected = self.resolve_channel(channel)?; if let Err(error) = validate_update_target(&self.config.version, &target, selected) { self.events - .error_terminal(None, Some("preflight"), error.to_string()); + .error_terminal(None, Some("preflight"), error.message().to_owned()); return Err(error); } let status = self.reconcile_installation()?; @@ -109,6 +119,7 @@ impl ProcessSystemOperations { &target, &self.config.version, file_lock, + self.diagnostics.clone(), )?; let accepted = SystemOperationAccepted::Update { operation_id: operation_id.clone(), @@ -125,7 +136,18 @@ impl ProcessSystemOperations { () = service.cancellation.cancelled() => Err(conflict("服务关闭,更新已中断")), result = service.perform_update_inner(&target, selected, &operation_id, preflight.as_ref()) => result, }; - if let Err(error) = operation.complete(&result) { + let completion = operation.complete(&result); + if let Err(error) = &result { + service + .diagnostics + .record("install", Some(&operation_id), error) + .await; + } + if let Err(error) = completion { + service + .diagnostics + .record("persist_update_status", Some(&operation_id), &error) + .await; tracing::warn!(error = %error, "系统更新终态落盘失败"); return; } @@ -138,7 +160,7 @@ impl ProcessSystemOperations { Err(error) => service.events.error_terminal( Some(&operation_id), Some("failed"), - error.to_string(), + error.message().to_owned(), ), } })); @@ -156,7 +178,7 @@ impl ProcessSystemOperations { .config .update_repository .as_deref() - .ok_or_else(|| conflict("update repository is not configured"))?; + .ok_or_else(|| conflict("未配置更新仓库 CPR_UPDATE_REPOSITORY"))?; self.events.info( Some(operation_id), Some("release"), @@ -211,22 +233,22 @@ impl ProcessSystemOperations { self.events .info(Some(operation_id), Some("verify"), "正在校验更新资源"); if archive.size == 0 || archive.size > MAX_DOWNLOAD_SIZE { - return Err(invalid("release archive size is invalid")); + return Err(invalid("更新包大小无效或超出限制")); } let checksum = release .assets .iter() .find(|asset| asset.name == "checksums.txt") - .ok_or_else(|| upstream("release checksums.txt is required"))?; + .ok_or_else(|| upstream("发行版本缺少 checksums.txt 校验文件,无法安全更新"))?; if checksum.size == 0 || checksum.size > MAX_CHECKSUM_SIZE { - return Err(invalid("release checksum size is invalid")); + return Err(invalid("校验文件大小无效或超出限制")); } self.events .info(Some(operation_id), Some("prepare"), "正在创建临时更新目录"); fs::create_dir_all(&self.config.update_temp_dir) - .map_err(|error| internal(format!("failed to prepare update temp dir: {error}")))?; + .map_err(|error| internal("无法创建更新临时目录").with_source(error))?; let temp_root = fs::canonicalize(&self.config.update_temp_dir) - .map_err(|error| internal(format!("failed to resolve update temp dir: {error}")))?; + .map_err(|error| internal("无法确定更新临时目录位置").with_source(error))?; let temp = UpdateTempDir::create(&temp_root)?; let archive_path = temp.path().join(&archive.name); self.events @@ -283,7 +305,7 @@ impl ProcessSystemOperations { self.config.web_dist_dir()?, extracted, )?; - let mut applied = AppliedReleaseGuard::new(&self.config); + let mut applied = AppliedReleaseGuard::new(&self.config, &self.diagnostics); let applied_manifest = read_release_manifest(&self.config.official_plugins_dir()?)?; if applied_manifest.as_ref() != release_manifest.as_ref() { return Err(applied.rollback(conflict("已应用的更新候选与预检候选不一致"))); @@ -368,12 +390,15 @@ impl SystemOperations for ProcessSystemOperations { .await { Ok(detail) => Ok(detail), - Err(error) => Ok(base_update_detail( - &self.config, - channel, - self.config.update_support_error(), - Some(error.to_string()), - )), + Err(error) => { + self.diagnostics.record("check_release", None, &error).await; + Ok(base_update_detail( + &self.config, + channel, + self.config.update_support_error(), + Some(error.message().to_owned()), + )) + } } } @@ -454,7 +479,7 @@ impl SystemOperations for ProcessSystemOperations { )?; let result = match rollback_release(&self.config) { Ok(()) => { - let mut applied = AppliedReleaseGuard::new(&self.config); + let mut applied = AppliedReleaseGuard::new(&self.config, &self.diagnostics); let result = match read_release_manifest(&self.config.official_plugins_dir()?) { Ok(manifest) if manifest.as_ref() == release_manifest.as_ref() => { preflight.confirm_revision(plugin_revision).await @@ -478,24 +503,28 @@ impl SystemOperations for ProcessSystemOperations { SystemOperationKind::Rollback, None, None, - result.as_ref().err().map(ToString::to_string), + result + .as_ref() + .err() + .map(|error| error.message().to_owned()), )?; match &result { Ok(()) => self.events.success_terminal( Some(&operation_id), Some("done"), - "previous release restored", + "上一版本已恢复,请重启服务", + ), + Err(error) => self.events.error_terminal( + Some(&operation_id), + Some("failed"), + error.message().to_owned(), ), - Err(error) => { - self.events - .error_terminal(Some(&operation_id), Some("failed"), error.to_string()) - } } drop(file_lock); result?; Ok(SystemOperationAccepted::Rollback { operation_id, - message: "回滚完成,请重启服务。".to_owned(), + message: "回滚完成,请重启服务".to_owned(), need_restart: true, }) } @@ -513,7 +542,7 @@ impl SystemOperations for ProcessSystemOperations { // 载入半成品产物 let mut operation = self.lock_operation()?; if !self.config.self_restart_enabled { - return Err(conflict("self restart is disabled")); + return Err(conflict("当前部署未启用在线重启")); } let file_lock = OperationFileLock::acquire(&self.config.update_lock_file)?; let candidate = self.installed_restart_candidate()?; @@ -555,36 +584,40 @@ impl SystemOperations for ProcessSystemOperations { fn read_release_manifest(directory: &Path) -> Result, OperationError> { let path = directory.join(OFFICIAL_PLUGIN_MANIFEST); let metadata = fs::symlink_metadata(&path) - .map_err(|error| invalid(format!("official plugin manifest is unavailable: {error}")))?; + .map_err(|error| invalid("无法访问官方插件发行清单").with_source(error))?; if !metadata.file_type().is_file() || metadata.file_type().is_symlink() { - return Err(invalid("official plugin manifest is not a regular file")); + return Err(invalid("官方插件发行清单必须是普通文件")); } if metadata.len() == 0 || metadata.len() > MAX_OFFICIAL_PLUGIN_MANIFEST_SIZE { - return Err(invalid("official plugin manifest size is invalid")); + return Err(invalid("官方插件发行清单大小无效或超出限制")); } fs::read(path) .map(Arc::<[u8]>::from) - .map_err(|error| invalid(format!("official plugin manifest cannot be read: {error}"))) + .map_err(|error| invalid("无法读取官方插件发行清单").with_source(error)) } fn restore_applied_release(config: &SystemUpdateConfig, cause: OperationError) -> OperationError { match rollback_release(config) { Ok(()) => cause, - Err(error) => internal(format!( - "release verification failed and restoring the previous files failed: {error}" - )), + Err(error) => internal("更新校验失败,且无法恢复原文件") + .with_source(gateway_core::error::ErrorSource::new(cause).with_cleanup(error)), } } struct AppliedReleaseGuard<'a> { config: &'a SystemUpdateConfig, + diagnostics: &'a super::diagnostics::Diagnostics, armed: bool, } impl<'a> AppliedReleaseGuard<'a> { - const fn new(config: &'a SystemUpdateConfig) -> Self { + const fn new( + config: &'a SystemUpdateConfig, + diagnostics: &'a super::diagnostics::Diagnostics, + ) -> Self { Self { config, + diagnostics, armed: true, } } @@ -605,6 +638,8 @@ impl Drop for AppliedReleaseGuard<'_> { && let Err(error) = rollback_release(self.config) { tracing::error!(error = %error, "系统发行物临界区取消后的恢复失败"); + self.diagnostics + .record_on_drop("restore_cancelled_installation", None, error); } } } diff --git a/backend/crates/gateway-host/src/system_update/process.rs b/backend/crates/gateway-host/src/system_update/process.rs index d95fe6fd9..1c60d975a 100644 --- a/backend/crates/gateway-host/src/system_update/process.rs +++ b/backend/crates/gateway-host/src/system_update/process.rs @@ -13,19 +13,15 @@ const DEFAULT_RESTART_DELAY_MS: u64 = 1_200; pub(crate) fn spawn_replacement(config: &SystemUpdateConfig) -> Result<(), OperationError> { let executable = config.executable_path()?; let metadata = fs::metadata(&executable) - .map_err(|error| internal(format!("failed to schedule replacement process: {error}")))?; + .map_err(|error| internal("无法启动替换进程").with_source(error))?; if !metadata.is_file() { - return Err(internal( - "failed to schedule replacement process: executable is not a file", - )); + return Err(internal("当前程序不是普通文件,无法在线重启")); } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; if metadata.permissions().mode() & 0o111 == 0 { - return Err(internal( - "failed to schedule replacement process: executable is not executable", - )); + return Err(internal("当前程序没有执行权限,无法在线重启")); } } @@ -58,5 +54,5 @@ pub(crate) fn spawn_replacement(config: &SystemUpdateConfig) -> Result<(), Opera command .spawn() .map(|_| ()) - .map_err(|error| internal(format!("failed to schedule replacement process: {error}"))) + .map_err(|error| internal("无法启动替换进程").with_source(error)) } diff --git a/backend/crates/gateway-host/src/system_update/release.rs b/backend/crates/gateway-host/src/system_update/release.rs index 0544d4aef..de93dab6e 100644 --- a/backend/crates/gateway-host/src/system_update/release.rs +++ b/backend/crates/gateway-host/src/system_update/release.rs @@ -60,7 +60,7 @@ impl ReleaseCache { let repository = config .update_repository .as_deref() - .ok_or_else(|| conflict("update repository is not configured"))?; + .ok_or_else(|| conflict("未配置更新仓库 CPR_UPDATE_REPOSITORY"))?; validate_repository(repository)?; validate_api_base(&config.github_api_base).map_err(conflict)?; let key = format!("{}|{}", config.release_cache_key(), channel.as_str()); @@ -127,12 +127,12 @@ pub(crate) async fn fetch_latest( validate_repository(repository)?; let base = api_base.trim_end_matches('/'); let current = semver::Version::parse(&normalize_version(current_version)) - .map_err(|_| invalid("current version is invalid"))?; + .map_err(|_| invalid("当前版本号无效,无法检查更新"))?; let client = reqwest::Client::builder() .timeout(Duration::from_secs(30)) .redirect(reqwest::redirect::Policy::none()) .build() - .map_err(|error| upstream(format!("failed to create release client: {error}")))?; + .map_err(|error| super::error::http("无法初始化发行查询连接", error))?; // 所有发行线先过滤再排序,避免更高大版本或其他实验遮住仍可安装的更新 let mut latest: Option<(semver::Version, GitHubRelease)> = None; @@ -184,17 +184,17 @@ async fn fetch_release_json( .header(reqwest::header::USER_AGENT, APP_BINARY_NAME) .send() .await - .map_err(|error| upstream(format!("GitHub release check failed: {error}")))?; + .map_err(|error| super::error::http("无法查询 GitHub 发行版本", error))?; if !response.status().is_success() { return Err(upstream(format!( - "GitHub release check failed with {}", - response.status() + "GitHub 发行查询失败(HTTP {})", + response.status().as_u16() ))); } response .json::() .await - .map_err(|error| upstream(format!("invalid GitHub release response: {error}"))) + .map_err(|error| super::error::http("GitHub 发行信息格式无效", error)) } pub(crate) fn detail_from_release( @@ -227,10 +227,10 @@ pub(crate) fn detail_from_release( } pub(crate) fn confirmed_target(target: Option) -> Result { - let target = target.ok_or_else(|| conflict("target version must be confirmed"))?; + let target = target.ok_or_else(|| conflict("请先确认目标版本"))?; let target = normalize_version(&target); if target.is_empty() || semver::Version::parse(&target).is_err() { - return Err(invalid("target version is invalid")); + return Err(invalid("目标版本号格式无效")); } Ok(target) } @@ -258,7 +258,7 @@ pub(crate) fn select_archive<'a>( }) .ok_or_else(|| { conflict(format!( - "no compatible release archive for {}/{}", + "当前发行版本没有适用于 {}/{} 的更新包", env::consts::OS, env::consts::ARCH )) @@ -268,20 +268,20 @@ pub(crate) fn select_archive<'a>( /// 校验 GitHub Release 资产 URL 及其重定向目标是否位于受信任边界内 pub fn validate_download_url(raw: &str, api_base: &str) -> Result<(), OperationError> { let url = reqwest::Url::parse(raw) - .map_err(|error| invalid(format!("invalid download URL: {error}")))?; + .map_err(|error| invalid("更新文件下载地址格式无效").with_source(error))?; if local_download_allowed(&url, api_base) { return Ok(()); } if url.scheme() != "https" { - return Err(invalid("only HTTPS release downloads are allowed")); + return Err(invalid("更新文件必须通过 HTTPS 下载")); } let host = url .host_str() - .ok_or_else(|| invalid("download URL is missing host"))?; + .ok_or_else(|| invalid("更新文件下载地址缺少主机名"))?; if github_download_host_allowed(host) { Ok(()) } else { - Err(invalid("release download host is not trusted")) + Err(invalid("更新文件下载地址不在受信任的发行站点范围内")) } } @@ -302,7 +302,7 @@ pub(crate) fn download_client( } })) .build() - .map_err(|error| upstream(format!("failed to create download client: {error}"))) + .map_err(|error| super::error::http("无法初始化下载连接", error)) } pub(crate) fn validate_repository(repository: &str) -> Result<(), OperationError> { @@ -315,30 +315,30 @@ pub(crate) fn validate_repository(repository: &str) -> Result<(), OperationError || !owner.chars().all(repository_character) || !name.chars().all(repository_character) { - return Err(conflict("update repository must use owner/repository")); + return Err(conflict("更新仓库格式应为 owner/repository")); } Ok(()) } pub(crate) fn validate_api_base(raw: &str) -> Result<(), String> { - let url = reqwest::Url::parse(raw).map_err(|error| format!("invalid API base: {error}"))?; + let url = reqwest::Url::parse(raw).map_err(|_| "发行查询地址格式无效".to_owned())?; if !url.username().is_empty() || url.password().is_some() || url.query().is_some() || url.fragment().is_some() { - return Err("API base must not include credentials, query, or fragment".to_owned()); + return Err("发行查询地址不能包含认证信息、查询参数或片段".to_owned()); } if url.path().trim_end_matches('/') != "/repos" { - return Err("API base path must be /repos".to_owned()); + return Err("发行查询地址的路径必须为 /repos".to_owned()); } if url_host_is_loopback(&url) { return matches!(url.scheme(), "http" | "https") .then_some(()) - .ok_or_else(|| "loopback API base must use HTTP or HTTPS".to_owned()); + .ok_or_else(|| "本地发行查询地址必须使用 HTTP 或 HTTPS".to_owned()); } if url.scheme() != "https" || url.host_str() != Some("api.github.com") { - return Err("API base must be https://api.github.com/repos".to_owned()); + return Err("发行查询地址必须为 https://api.github.com/repos".to_owned()); } Ok(()) } @@ -409,7 +409,7 @@ pub(crate) fn validate_update_target( let current = semver::Version::parse(&normalize_version(current)) .map_err(|_| conflict("当前版本格式无效"))?; let target = semver::Version::parse(&normalize_version(target)) - .map_err(|_| invalid("target version is invalid"))?; + .map_err(|_| invalid("目标版本号格式无效"))?; if !update_target_allowed(¤t, &target, channel) { return Err(conflict( "所选通道不允许此目标,或目标涉及跨实验线、跨大版本、降级", @@ -494,13 +494,13 @@ impl SystemUpdateConfig { return Some("检查更新需要配置 CPR_UPDATE_REPOSITORY".to_owned()); }; if let Err(error) = validate_repository(repository) { - return Some(error.to_string()); + return Some(error.message().to_owned()); } if let Err(error) = validate_api_base(&self.github_api_base) { return Some(error); } if let Err(error) = self.web_dist_dir() { - return Some(error.to_string()); + return Some(error.message().to_owned()); } None } diff --git a/backend/crates/gateway-host/src/system_update/state.rs b/backend/crates/gateway-host/src/system_update/state.rs index 274208a78..6c2e87242 100644 --- a/backend/crates/gateway-host/src/system_update/state.rs +++ b/backend/crates/gateway-host/src/system_update/state.rs @@ -71,29 +71,27 @@ impl OperationFileLock { .parent() .filter(|parent| !parent.as_os_str().is_empty()) { - fs::create_dir_all(parent).map_err(|error| { - internal(format!("failed to create update lock directory: {error}")) - })?; + fs::create_dir_all(parent) + .map_err(|error| internal("无法创建更新锁目录").with_source(error))?; } match Self::try_create(path) { Ok(()) => Ok(Self { path: path.into() }), Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { if !stale_lock(path)? { - return Err(conflict("system update already running")); + return Err(conflict("系统更新正在执行")); } - fs::remove_file(path).map_err(|error| { - internal(format!("failed to remove stale update lock: {error}")) - })?; + fs::remove_file(path) + .map_err(|error| internal("无法清理过期更新锁").with_source(error))?; Self::try_create(path).map_err(|error| { if error.kind() == io::ErrorKind::AlreadyExists { - conflict("system update already running") + conflict("系统更新正在执行") } else { - internal(format!("failed to create update lock: {error}")) + internal("无法创建更新锁").with_source(error) } })?; Ok(Self { path: path.into() }) } - Err(error) => Err(internal(format!("failed to create update lock: {error}"))), + Err(error) => Err(internal("无法创建更新锁").with_source(error)), } } @@ -137,13 +135,11 @@ impl UpdateTempDir { Ok(()) => return Ok(Self { path }), Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, Err(error) => { - return Err(internal(format!( - "failed to create update temp directory: {error}" - ))); + return Err(internal("无法创建更新临时目录").with_source(error)); } } } - Err(internal("failed to create unique update temp directory")) + Err(internal("无法分配更新临时目录")) } pub(crate) fn path(&self) -> &Path { @@ -168,6 +164,7 @@ pub(crate) struct UpdateOperation { target: String, completed: bool, _file_lock: OperationFileLock, + diagnostics: super::diagnostics::Diagnostics, } impl UpdateOperation { @@ -177,6 +174,7 @@ impl UpdateOperation { target: &str, current_version: &str, file_lock: OperationFileLock, + diagnostics: super::diagnostics::Diagnostics, ) -> Result { set_running( path, @@ -191,6 +189,7 @@ impl UpdateOperation { target: target.to_owned(), completed: false, _file_lock: file_lock, + diagnostics, }) } @@ -204,7 +203,10 @@ impl UpdateOperation { SystemOperationKind::Update, result.as_ref().ok().map(|_| self.target.clone()), result.as_ref().ok().cloned(), - result.as_ref().err().map(ToString::to_string), + result + .as_ref() + .err() + .map(|error| error.message().to_owned()), )?; self.completed = true; Ok(()) @@ -224,6 +226,11 @@ impl Drop for UpdateOperation { ) { tracing::warn!(error = %error, "收敛中断的系统更新状态失败"); + self.diagnostics.record_on_drop( + "persist_interrupted_update", + Some(self.operation_id.clone()), + error, + ); } } } @@ -277,7 +284,7 @@ pub(crate) fn set_running( kind: Some(kind.into()), status: PersistedStatus::Running, target_version: target_version.map(ToOwned::to_owned), - message: Some("operation running".to_owned()), + message: Some("操作正在执行".to_owned()), error: None, started_at: Some(Utc::now().to_rfc3339()), finished_at: None, @@ -303,7 +310,7 @@ pub(crate) fn finish( state.operation.error = Some(error); } else { state.operation.status = PersistedStatus::Succeeded; - state.operation.message = Some("operation succeeded".to_owned()); + state.operation.message = Some("操作已完成".to_owned()); state.operation.error = None; match kind { SystemOperationKind::Update => { @@ -398,11 +405,11 @@ pub(crate) fn reconcile_installation( fn stale_lock(path: &Path) -> Result { let modified = fs::metadata(path) .and_then(|metadata| metadata.modified()) - .map_err(|error| internal(format!("failed to read update lock timestamp: {error}")))?; + .map_err(|error| internal("无法读取更新锁时间").with_source(error))?; modified .elapsed() .map(|age| age > Duration::from_secs(30 * 60)) - .map_err(|error| internal(format!("failed to calculate update lock age: {error}"))) + .map_err(|error| internal("无法检查更新锁是否过期").with_source(error)) } fn read_persisted(path: &Path) -> Result { @@ -410,8 +417,8 @@ fn read_persisted(path: &Path) -> Result { return Ok(PersistedState::default()); } let data = fs::read_to_string(path) - .map_err(|error| internal(format!("failed to read update state: {error}")))?; - serde_json::from_str(&data).map_err(|error| internal(format!("invalid update state: {error}"))) + .map_err(|error| internal("无法读取系统更新状态").with_source(error))?; + serde_json::from_str(&data).map_err(|error| internal("系统更新状态文件损坏").with_source(error)) } fn write_persisted(path: &Path, state: &PersistedState) -> Result<(), OperationError> { @@ -419,12 +426,11 @@ fn write_persisted(path: &Path, state: &PersistedState) -> Result<(), OperationE .parent() .filter(|parent| !parent.as_os_str().is_empty()) { - fs::create_dir_all(parent).map_err(|error| { - internal(format!("failed to create update state directory: {error}")) - })?; + fs::create_dir_all(parent) + .map_err(|error| internal("无法创建系统更新状态目录").with_source(error))?; } let data = serde_json::to_vec_pretty(state) - .map_err(|error| internal(format!("failed to encode update state: {error}")))?; + .map_err(|error| internal("无法生成系统更新状态").with_source(error))?; let temporary = temporary_path(path); let result = (|| { let mut file = fs::OpenOptions::new() @@ -438,7 +444,7 @@ fn write_persisted(path: &Path, state: &PersistedState) -> Result<(), OperationE if result.is_err() { let _ = fs::remove_file(&temporary); } - result.map_err(|error| internal(format!("failed to write update state: {error}"))) + result.map_err(|error| internal("无法保存系统更新状态").with_source(error)) } fn temporary_path(path: &Path) -> PathBuf { diff --git a/backend/crates/gateway-host/src/system_update/swap.rs b/backend/crates/gateway-host/src/system_update/swap.rs index f1ad344aa..71c860004 100644 --- a/backend/crates/gateway-host/src/system_update/swap.rs +++ b/backend/crates/gateway-host/src/system_update/swap.rs @@ -1,10 +1,12 @@ //! 二进制与 Web 制品的交易式替换、跨文件系统移动与回滚 -use std::fmt; +use std::error::Error; use std::fs; use std::io; use std::path::{Path, PathBuf}; +use gateway_core::error::ErrorSource; + use super::archive::ExtractedRelease; use super::{OperationError, SystemUpdateConfig, conflict, internal}; @@ -15,18 +17,15 @@ pub(crate) fn replace_release_files( ) -> Result<(), OperationError> { let official_plugins = official_plugins_dir(executable)?; if !official_plugins.exists() { - fs::create_dir_all(&official_plugins).map_err(|error| { - internal(format!( - "failed to prepare official plugin rollback directory: {error}" - )) - })?; + fs::create_dir_all(&official_plugins) + .map_err(|error| internal("无法创建官方插件备份目录").with_source(error))?; } let official_backup = backup_path_for(&official_plugins); replace_dir( &official_plugins, &official_backup, &extracted.official_plugins_dir, - "official plugin assets", + "官方插件文件", )?; if let Err(error) = protect_official_plugins(&official_plugins) { let mut rollback = Vec::new(); @@ -36,7 +35,7 @@ pub(crate) fn replace_release_files( restore_dir(&official_plugins, &official_backup), ); return Err(error_with_rollback( - "failed to protect official plugin assets", + "无法设置官方插件文件权限", error, rollback, )); @@ -45,18 +44,14 @@ pub(crate) fn replace_release_files( let web_backup = backup_path_for(web_dist); let web_replaced = match extracted.web_dist_dir { Some(new_web) => { - if let Err(error) = replace_dir(web_dist, &web_backup, &new_web, "web assets") { + if let Err(error) = replace_dir(web_dist, &web_backup, &new_web, "管理页面文件") { let mut rollback = Vec::new(); collect_rollback_error( &mut rollback, "restore official plugin assets", restore_dir(&official_plugins, &official_backup), ); - return Err(error_with_rollback( - "failed to replace web assets", - error, - rollback, - )); + return Err(error_with_rollback("无法替换管理页面文件", error, rollback)); } true } @@ -80,11 +75,7 @@ pub(crate) fn replace_release_files( "restore official plugin assets", restore_dir(&official_plugins, &official_backup), ); - return Err(error_with_rollback( - "failed to remove old binary backup", - error, - rollback, - )); + return Err(error_with_rollback("无法移除旧的程序备份", error, rollback)); } if let Err(error) = move_file(executable, &binary_backup) { let mut rollback = Vec::new(); @@ -100,7 +91,7 @@ pub(crate) fn replace_release_files( "restore official plugin assets", restore_dir(&official_plugins, &official_backup), ); - return Err(error_with_rollback("binary backup failed", error, rollback)); + return Err(error_with_rollback("无法备份程序文件", error, rollback)); } if let Err(error) = move_file(&extracted.binary_path, executable) { let mut rollback = Vec::new(); @@ -128,11 +119,7 @@ pub(crate) fn replace_release_files( "restore official plugin assets", restore_dir(&official_plugins, &official_backup), ); - return Err(error_with_rollback( - "binary replace failed", - error, - rollback, - )); + return Err(error_with_rollback("无法替换程序文件", error, rollback)); } Ok(()) } @@ -142,10 +129,10 @@ pub(crate) fn rollback_release(config: &SystemUpdateConfig) -> Result<(), Operat let executable = config.executable_path()?; let binary_backup = backup_path_for(&executable); if !binary_backup.exists() { - return Err(conflict("no binary backup found for rollback")); + return Err(conflict("没有可用于回滚的程序备份")); } swap_file(&executable, &binary_backup) - .map_err(|error| internal(format!("binary rollback failed: {error}")))?; + .map_err(|error| internal("程序文件回滚失败").with_source(error))?; let web_backup = backup_path_for(web_dist); if web_backup.exists() @@ -157,7 +144,7 @@ pub(crate) fn rollback_release(config: &SystemUpdateConfig) -> Result<(), Operat "restore binary after web rollback failure", swap_file(&executable, &binary_backup), ); - return Err(error_with_rollback("web rollback failed", error, rollback)); + return Err(error_with_rollback("管理页面文件回滚失败", error, rollback)); } let official_plugins = config.official_plugins_dir()?; let official_backup = backup_path_for(&official_plugins); @@ -177,11 +164,7 @@ pub(crate) fn rollback_release(config: &SystemUpdateConfig) -> Result<(), Operat "restore binary after official plugin rollback failure", swap_file(&executable, &binary_backup), ); - return Err(error_with_rollback( - "official plugin rollback failed", - error, - rollback, - )); + return Err(error_with_rollback("官方插件文件回滚失败", error, rollback)); } Ok(()) } @@ -199,7 +182,7 @@ fn official_plugins_dir(executable: &Path) -> Result { .parent() .filter(|path| !path.as_os_str().is_empty()) .map(|path| path.join("plugins").join("official")) - .ok_or_else(|| internal("failed to resolve official plugin release directory")) + .ok_or_else(|| internal("无法确定官方插件安装目录")) } fn replace_dir( @@ -210,11 +193,11 @@ fn replace_dir( ) -> Result<(), OperationError> { if backup.exists() { remove_dir_all(backup) - .map_err(|error| internal(format!("failed to remove old {label} backup: {error}")))?; + .map_err(|error| internal(format!("无法移除旧的{label}备份")).with_source(error))?; } if current.exists() { move_dir(current, backup) - .map_err(|error| internal(format!("failed to backup {label}: {error}")))?; + .map_err(|error| internal(format!("无法备份{label}")).with_source(error))?; } if let Err(error) = move_dir(replacement, current) { let mut rollback = Vec::new(); @@ -225,11 +208,7 @@ fn replace_dir( restore_dir(current, backup), ); } - return Err(error_with_rollback( - "failed to replace directory contents", - error, - rollback, - )); + return Err(error_with_rollback("无法替换安装目录内容", error, rollback)); } Ok(()) } @@ -391,14 +370,13 @@ fn collect_rollback_error(errors: &mut Vec, action: &'static str, result fn error_with_rollback( context: &'static str, - error: impl fmt::Display, + error: impl Error + Send + Sync + 'static, rollback_errors: Vec, ) -> OperationError { if rollback_errors.is_empty() { - return internal(format!("{context}: {error}")); + return internal(context).with_source(error); } - internal(format!( - "{context}: {error}; rollback failed: {}", - rollback_errors.join("; ") - )) + internal(format!("{context},且无法恢复原文件")).with_source( + ErrorSource::new(error).with_cleanup(io::Error::other(rollback_errors.join("; "))), + ) } diff --git a/backend/crates/gateway-host/tests/system_update/events.rs b/backend/crates/gateway-host/tests/system_update/events.rs index f232a2b4f..5793d0cef 100644 --- a/backend/crates/gateway-host/tests/system_update/events.rs +++ b/backend/crates/gateway-host/tests/system_update/events.rs @@ -2,6 +2,76 @@ use super::*; +#[tokio::test] +async fn download_failure_should_not_publish_download_url_in_events_or_status() { + for diagnostics_result in [None, Some(false), Some(true)] { + let server = MockServer::start().await; + let fixture = Fixture::new(); + fixture + .mount_custom_release( + &server, + &format!("{}/archive?token=private-download-sentinel", server.uri()), + None, + ) + .await; + Mock::given(method("GET")) + .and(path("/archive")) + .respond_with( + ResponseTemplate::new(302) + .insert_header("location", "https://untrusted.example/archive"), + ) + .mount(&server) + .await; + let service = fixture.service(&server); + let diagnostics = Arc::new(RecordingDiagnostics { + reject: diagnostics_result == Some(true), + ..RecordingDiagnostics::default() + }); + if diagnostics_result.is_some() { + service.set_diagnostics(diagnostics.clone()); + } + let events = service.update_events(); + let status = complete_update(&service, TARGET_VERSION).await; + assert_eq!(status.operation.status, SystemOperationStatus::Failed); + assert_eq!(status.operation.error.as_deref(), Some("更新包下载失败")); + let events = events.collect::>().await; + let terminal = events.last().expect("terminal event"); + assert!(terminal.terminal); + assert_eq!( + Some(terminal.message.as_str()), + status.operation.error.as_deref() + ); + assert!( + events + .iter() + .all(|event| !event.message.contains("private-download-sentinel")) + ); + assert!( + !fs::read_to_string(fixture.state()) + .expect("persisted state") + .contains("private-download-sentinel") + ); + if diagnostics_result.is_some() { + let failures = diagnostics.failures.lock().expect("diagnostic failures"); + assert_eq!(failures.len(), 1); + let failure = &failures[0]; + assert_eq!(failure.operation, "install"); + assert_eq!(failure.correlation_id, status.operation.operation_id); + let details = failure.details.as_ref().expect("download failure details"); + assert!(!details.as_str().contains("private-download-sentinel")); + assert!(!details.as_str().contains(&server.uri())); + let details: serde_json::Value = + serde_json::from_str(details.as_str()).expect("details JSON"); + assert_eq!(details["redacted"], true); + assert!( + details["causes"]["messages"] + .as_array() + .is_some_and(|messages| !messages.is_empty()) + ); + } + } +} + #[tokio::test] async fn update_events_should_close_after_terminal_update_log() { let fixture = Fixture::new(); @@ -144,6 +214,11 @@ async fn terminal_event_should_only_be_visible_after_status_is_persisted() { let status = service.update_status().await.expect("terminal status"); assert_eq!(status.operation.status, SystemOperationStatus::Failed); assert!(status.operation.finished_at.is_some()); + assert_eq!(event.message, "更新包校验失败,请重新下载"); + assert_eq!( + status.operation.error.as_deref(), + Some(event.message.as_str()) + ); return; } } diff --git a/backend/crates/gateway-host/tests/system_update/mod.rs b/backend/crates/gateway-host/tests/system_update/mod.rs index 0e32b062f..1871aeffe 100644 --- a/backend/crates/gateway-host/tests/system_update/mod.rs +++ b/backend/crates/gateway-host/tests/system_update/mod.rs @@ -45,6 +45,32 @@ const OLD_RELEASE_MANIFEST: &str = r#"{"gateway_version":"1.0.0"}"#; const TARGET_VERSION: &str = "1.9.9"; const CROSS_MAJOR_VERSION: &str = "2.0.0"; +#[derive(Default)] +struct RecordingDiagnostics { + failures: std::sync::Mutex>, + reject: bool, +} + +#[async_trait::async_trait] +impl gateway_core::diagnostics::OperationalDiagnostics for RecordingDiagnostics { + async fn record_failure( + &self, + failure: gateway_core::diagnostics::OperationalFailure, + ) -> Result<(), gateway_core::error::StoreError> { + self.failures + .lock() + .expect("diagnostic failures") + .push(failure); + if self.reject { + Err(gateway_core::error::StoreError::new( + gateway_core::error::StoreErrorKind::Unavailable, + )) + } else { + Ok(()) + } + } +} + struct AllowingUpdatePreflight; #[async_trait::async_trait] impl gateway_admin::ports::system::SystemRestartPreflight for AllowingUpdatePreflight { diff --git a/backend/crates/gateway-host/tests/system_update/release.rs b/backend/crates/gateway-host/tests/system_update/release.rs index b54675d06..e40d3c237 100644 --- a/backend/crates/gateway-host/tests/system_update/release.rs +++ b/backend/crates/gateway-host/tests/system_update/release.rs @@ -2,6 +2,32 @@ use super::*; +#[tokio::test] +async fn update_detail_should_record_decode_failure_while_returning_safe_warning() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/owner/repository/releases")) + .respond_with(ResponseTemplate::new(200).set_body_string("not release JSON")) + .mount(&server) + .await; + let fixture = Fixture::new(); + let service = fixture.service(&server); + let diagnostics = Arc::new(RecordingDiagnostics::default()); + service.set_diagnostics(diagnostics.clone()); + + let detail = service + .update_detail(true, None) + .await + .expect("failure detail"); + assert_eq!(detail.warning.as_deref(), Some("GitHub 发行信息格式无效")); + let failures = diagnostics.failures.lock().expect("diagnostic failures"); + assert_eq!(failures.len(), 1); + assert_eq!(failures[0].operation, "check_release"); + let details = failures[0].details.as_ref().expect("decoding details"); + assert!(details.as_str().contains("expected ident")); + assert!(!details.as_str().contains(&server.uri())); +} + #[tokio::test] async fn update_detail_should_report_refresh_failure_and_clear_previous_update() { let server = MockServer::start().await; @@ -18,7 +44,10 @@ async fn update_detail_should_report_refresh_failure_and_clear_previous_update() .await .expect("failure detail"); assert!(!detail.has_update); - assert!(detail.warning.is_some()); + assert_eq!( + detail.warning.as_deref(), + Some("GitHub 发行查询失败(HTTP 404)") + ); assert!(!detail.cached); assert_eq!(detail.latest_version, "1.0.0"); assert!(detail.notes.is_none()); diff --git a/backend/crates/gateway-host/tests/system_update/state.rs b/backend/crates/gateway-host/tests/system_update/state.rs index 04cf95e83..3c1dd8af4 100644 --- a/backend/crates/gateway-host/tests/system_update/state.rs +++ b/backend/crates/gateway-host/tests/system_update/state.rs @@ -2,6 +2,27 @@ use super::*; +#[tokio::test] +async fn invalid_update_state_should_keep_raw_details_out_of_public_error() { + use std::error::Error as _; + + let fixture = Fixture::new(); + fs::write( + fixture.state(), + r#"{"operation":{"status":"private-state-sentinel"}}"#, + ) + .expect("invalid state"); + let service = fixture.service_for_url("http://127.0.0.1:1/repos"); + let error = service.update_status().await.expect_err("invalid state"); + + assert_eq!(error.kind(), SystemOperationErrorKind::Internal); + assert_eq!(error.message(), "系统更新状态文件损坏"); + assert!(!format!("{error} {error:?}").contains("private-state-sentinel")); + let source = error.source().expect("state decoding error"); + assert!(source.is::()); + assert!(source.to_string().contains("private-state-sentinel")); +} + #[tokio::test] async fn update_status_should_reconcile_manual_deployment_without_reusing_legacy_success() { let fixture = Fixture::new(); diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/middleware/outbound.rs b/backend/crates/gateway-plugin/runtime/src/adapter/middleware/outbound.rs index d0fb5f0a5..7962f2f8d 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/middleware/outbound.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/middleware/outbound.rs @@ -86,7 +86,7 @@ pub(super) async fn invoke( () = context.cancellation().cancelled() => return Err(MiddlewareError::Fault), () = context.deadline().wait() => return Err(gateway_core::error::GatewayError::new( gateway_core::error::GatewayErrorKind::Timeout, - "request deadline elapsed", + "Request deadline elapsed.", ).into()), reply = ports.session.call( HANDLE_METHOD, diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/middleware/request.rs b/backend/crates/gateway-plugin/runtime/src/adapter/middleware/request.rs index 51ded81d0..bbe559c69 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/middleware/request.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/middleware/request.rs @@ -137,7 +137,7 @@ pub(super) async fn invoke( () = context.cancellation().cancelled() => return Err(MiddlewareError::Fault), () = context.deadline().wait() => return Err(gateway_core::error::GatewayError::new( gateway_core::error::GatewayErrorKind::Timeout, - "request deadline elapsed", + "Request deadline elapsed.", ).into()), result = invocation_ports.session.call_stream( HANDLE_METHOD, diff --git a/backend/crates/gateway-plugin/runtime/src/callback/error.rs b/backend/crates/gateway-plugin/runtime/src/callback/error.rs index 047b72733..8b20f84df 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/error.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/error.rs @@ -154,7 +154,7 @@ pub(crate) fn rpc_middleware(error: crate::RpcError) -> MiddlewareError { return MiddlewareError::Gateway( GatewayError::new( GatewayErrorKind::MiddlewareCapacityUnavailable, - "Request middleware is temporarily busy", + "Request middleware is temporarily busy.", ) .with_source(gateway_core::error::ErrorSource::new(error)), ); diff --git a/backend/crates/gateway-store/src/postgres/client_budgets.rs b/backend/crates/gateway-store/src/postgres/client_budgets.rs index 3dda853b1..fa3c556d4 100644 --- a/backend/crates/gateway-store/src/postgres/client_budgets.rs +++ b/backend/crates/gateway-store/src/postgres/client_budgets.rs @@ -170,11 +170,11 @@ impl PgClientBudgetStore { ) .bind(key_id.as_str()).bind(ids).fetch_optional(&self.pool).await .map_err(|source| unavailable().with_source(source))? - .ok_or_else(|| GatewayError::new(GatewayErrorKind::Unauthorized, "client API key no longer exists"))?; + .ok_or_else(|| GatewayError::new(GatewayErrorKind::Unauthorized, "Client API key no longer exists."))?; if !row.get::("enabled") { return Err(GatewayError::new( GatewayErrorKind::PolicyDenied, - "client API key is disabled", + "Client API key is disabled.", )); } let parse = |field: &str| -> Result { @@ -238,21 +238,22 @@ impl PgClientBudgetStore { let daily_exceeded = limits.daily_usd != Decimal::ZERO && daily >= limits.daily_usd; let weekly_exceeded = limits.weekly_usd != Decimal::ZERO && weekly >= limits.weekly_usd; if daily_exceeded || weekly_exceeded { - let reset = if weekly_exceeded { - weekly_end + let (reset, code, message) = if weekly_exceeded { + ( + weekly_end, + "key_weekly_budget_exceeded", + "Weekly spending limit reached.", + ) } else { - daily_end + ( + daily_end, + "key_daily_budget_exceeded", + "Daily spending limit reached.", + ) }; - return Err(GatewayError::new( - GatewayErrorKind::RateLimited, - "client API key budget is exhausted", - ) - .with_client_code(if weekly_exceeded { - "key_weekly_budget_exceeded" - } else { - "key_daily_budget_exceeded" - }) - .with_retry_after((reset - now).to_std().unwrap_or(Duration::from_secs(1)))); + return Err(GatewayError::new(GatewayErrorKind::RateLimited, message) + .with_client_code(code) + .with_retry_after((reset - now).to_std().unwrap_or(Duration::from_secs(1)))); } } // 首次使用或窗口到期才写入;与重置、结算保持相同锁序 @@ -270,7 +271,7 @@ impl PgClientBudgetStore { .ok_or_else(|| { GatewayError::new( GatewayErrorKind::Unauthorized, - "client API key no longer exists", + "Client API key no longer exists.", ) })?; advance_windows(&mut tx, key_id.as_str(), now, now, self.timezone) @@ -478,7 +479,7 @@ pub(super) async fn load_client_key_budgets( pub(super) fn unavailable() -> GatewayError { GatewayError::new( GatewayErrorKind::ProviderInfrastructureUnavailable, - "client budget service is temporarily unavailable", + "Client budget service is temporarily unavailable.", ) .with_client_code("key_budget_unavailable") } diff --git a/backend/crates/providers/openai/src/provider/failure.rs b/backend/crates/providers/openai/src/provider/failure.rs index 9c7635804..07c994e1a 100644 --- a/backend/crates/providers/openai/src/provider/failure.rs +++ b/backend/crates/providers/openai/src/provider/failure.rs @@ -794,7 +794,14 @@ pub(super) fn map_client_error( ProviderErrorKind::InvalidRequest, UpstreamSendState::NotSent, ) - .with_retry_prohibited(), + .with_retry_prohibited() + .with_client_visible_upstream_error( + ClientVisibleUpstreamError::new( + "A gateway privacy rule could not process this request.", + Some("privacy_policy_rejected".to_owned()), + Some("invalid_request_error".to_owned()), + ), + ), ) } CodexClientError::ConnectionBudgetExhausted => MappedProviderFailure::plain( @@ -971,7 +978,7 @@ pub(super) fn map_client_error( } /// 在消费 transport 错误前提取可持久化事实,不能使用可能携带 URL/凭据的 Display -fn client_diagnostic(error: &CodexClientError) -> Option { +pub(super) fn client_diagnostic(error: &CodexClientError) -> Option { let (stage, code, message) = match error { CodexClientError::ConnectionBudgetExhausted => ( "connect", @@ -1300,9 +1307,13 @@ pub(super) fn websocket_client_visible_error( .map_or_else( || { if message_too_big { - "upstream websocket message too big".to_owned() + "The upstream WebSocket message is too large.".to_owned() + } else if let Some(code) = close.code() { + format!( + "The upstream WebSocket closed before the response completed (code {code})." + ) } else { - close.to_string() + "The upstream WebSocket closed before the response completed.".to_owned() } }, str::to_owned, diff --git a/backend/crates/providers/openai/src/provider/live.rs b/backend/crates/providers/openai/src/provider/live.rs index d1387dc1b..6df1afd13 100644 --- a/backend/crates/providers/openai/src/provider/live.rs +++ b/backend/crates/providers/openai/src/provider/live.rs @@ -69,10 +69,10 @@ impl LiveClaimError { fn message(self) -> &'static str { match self { - Self::Missing => "codex live call not found", - Self::Busy => "codex live call already has a sideband connection", - Self::OwnerMismatch => "codex live call is outside the current API key scope", - Self::Invalid => "invalid codex live call id", + Self::Missing => "The voice call was not found or has expired.", + Self::Busy => "The voice call already has an active event connection.", + Self::OwnerMismatch => "This API key cannot access the voice call.", + Self::Invalid => "The voice call ID is invalid.", } } } @@ -562,23 +562,29 @@ impl CodexLiveGateway { .store() .get_account(account_id) .await - .map_err(|_| credential_unavailable("codex live pinned account is unavailable"))? - .ok_or_else(|| credential_unavailable("codex live pinned account is unavailable"))?; + .map_err(|_| credential_unavailable("The account for this voice call is unavailable."))? + .ok_or_else(|| { + credential_unavailable("The account for this voice call is unavailable.") + })?; if account.authentication_kind() != CODEX_AUTHENTICATION_KIND_OAUTH { return Err(LiveGatewayError::new( LiveGatewayErrorKind::Unsupported, - "codex live requires an OAuth codex account", + "Voice calls require an OpenAI OAuth account.", )); } let credential = self .repository .load_runtime_credential(&account) .await - .map_err(|_| credential_unavailable("codex live pinned credential is unavailable"))?; + .map_err(|_| { + credential_unavailable("The credentials for this voice call are unavailable.") + })?; let authorization = credential .authentication .authorization_header() - .map_err(|_| credential_unavailable("codex live pinned credential is unusable"))?; + .map_err(|_| { + credential_unavailable("The credentials for this voice call are invalid.") + })?; Ok((account, authorization, credential.installation_id)) } @@ -608,9 +614,9 @@ fn map_sideband_dial_error(error: CodexWebSocketExchangeError) -> LiveGatewayErr ) .with_upstream(upstream.status_code, body) } - other => LiveGatewayError::new( - LiveGatewayErrorKind::UpstreamUnavailable, - format!("codex live sideband upstream unavailable: {other}"), + other => local_live_error( + CodexClientError::WebSocket(other), + "The voice connection could not be established.", ), } } @@ -637,13 +643,23 @@ fn map_live_http_error(error: CodexClientError) -> LiveGatewayError { ), ) .with_upstream(status.as_u16(), Some(Bytes::from(body))), - other => LiveGatewayError::new( - LiveGatewayErrorKind::UpstreamUnavailable, - format!("codex live upstream call failed: {other}"), - ), + other => local_live_error(other, "The voice call request could not be completed."), } } +fn local_live_error(error: CodexClientError, message: &'static str) -> LiveGatewayError { + // 本地传输异常可能包含 URL 或代理上下文;客户端提示与现有安全诊断各自保留 + if let Some(diagnostic) = super::failure::client_diagnostic(&error) { + tracing::warn!( + stage = diagnostic.stage(), + code = diagnostic.code(), + detail = diagnostic.as_str(), + "Codex Live request failed" + ); + } + LiveGatewayError::new(LiveGatewayErrorKind::UpstreamUnavailable, message) +} + impl LiveGateway for CodexLiveGateway { fn open_sideband<'a>( &'a self, @@ -667,7 +683,7 @@ impl LiveGateway for CodexLiveGateway { if !account.enabled() { return Err(LiveGatewayError::new( LiveGatewayErrorKind::OwnerMismatch, - "codex live pinned account is disabled", + "The account for this voice call is disabled.", )); } let endpoint = Self::sideband_endpoint(request.style, request.call_id); @@ -732,7 +748,7 @@ impl LiveGateway for CodexLiveGateway { .map_err(|_| { LiveGatewayError::new( LiveGatewayErrorKind::CredentialUnavailable, - "codex live hangup account client is unavailable", + "The account connection for this voice call is unavailable.", ) })?; let request_id = Uuid::new_v4().to_string(); diff --git a/backend/crates/providers/openai/src/provider/observation.rs b/backend/crates/providers/openai/src/provider/observation.rs index bbe36ac33..d6eceafcc 100644 --- a/backend/crates/providers/openai/src/provider/observation.rs +++ b/backend/crates/providers/openai/src/provider/observation.rs @@ -789,9 +789,16 @@ fn selection_failure(error: &CredentialSelectionError) -> ProviderError { ProviderDiagnostic::new(error.to_string()).with_classification("account_selection", code) }); let mapped = match error { - CredentialSelectionError::Cancelled => provider_error(ProviderErrorKind::Cancelled, UpstreamSendState::NotSent).with_retry_prohibited(), - CredentialSelectionError::ContinuationOwnerChanged => continuation_replay_required_error("scope_unavailable"), - CredentialSelectionError::SessionBound(error) => selection_failure(error).with_retry_prohibited(), + CredentialSelectionError::Cancelled => { + provider_error(ProviderErrorKind::Cancelled, UpstreamSendState::NotSent) + .with_retry_prohibited() + } + CredentialSelectionError::ContinuationOwnerChanged => { + continuation_replay_required_error("scope_unavailable") + } + CredentialSelectionError::SessionBound(error) => { + selection_failure(error).with_retry_prohibited() + } CredentialSelectionError::QueueRejected(error) => { provider_error(error.provider_kind(), UpstreamSendState::NotSent) } @@ -811,7 +818,7 @@ fn selection_failure(error: &CredentialSelectionError) -> ProviderError { ) .with_retry_prohibited() .with_client_visible_upstream_error(ClientVisibleUpstreamError::new( - "No account is available for this session because of an earlier upstream cyber_policy rejection. Do not repeatedly retry this session. This does not indicate an account ban or a service-wide outage.", + "No account is available for this session after an upstream cyber policy rejection.", Some("cyber_policy".to_owned()), Some("invalid_request_error".to_owned()), )), @@ -824,7 +831,7 @@ fn selection_failure(error: &CredentialSelectionError) -> ProviderError { UpstreamSendState::NotSent, ) .with_client_visible_upstream_error(ClientVisibleUpstreamError::new( - "All eligible accounts have exhausted their quota. Retry after quota resets or add an available account.", + "All eligible accounts have exhausted their quota.", Some("usage_limit_reached".to_owned()), Some("usage_limit_reached".to_owned()), )), @@ -844,14 +851,15 @@ fn selection_failure(error: &CredentialSelectionError) -> ProviderError { ProviderErrorKind::ProviderInfrastructureUnavailable, UpstreamSendState::NotSent, ), - CredentialSelectionError::PolicyRejected | CredentialSelectionError::PolicyFiltered => provider_error( - ProviderErrorKind::RequestPolicyDenied, - UpstreamSendState::NotSent, - ), - CredentialSelectionError::PolicyUnavailable => provider_error( - ProviderErrorKind::Unavailable, - UpstreamSendState::NotSent, - ), + CredentialSelectionError::PolicyRejected | CredentialSelectionError::PolicyFiltered => { + provider_error( + ProviderErrorKind::RequestPolicyDenied, + UpstreamSendState::NotSent, + ) + } + CredentialSelectionError::PolicyUnavailable => { + provider_error(ProviderErrorKind::Unavailable, UpstreamSendState::NotSent) + } }; match diagnostic { Some(diagnostic) => mapped.with_diagnostic(diagnostic), diff --git a/backend/crates/providers/openai/src/transport/protocol/responses.rs b/backend/crates/providers/openai/src/transport/protocol/responses.rs index 73c17305f..0ba8ab6c2 100644 --- a/backend/crates/providers/openai/src/transport/protocol/responses.rs +++ b/backend/crates/providers/openai/src/transport/protocol/responses.rs @@ -524,7 +524,8 @@ impl ResponsesSseFailure { pub(crate) fn from_raw_event(event: &str, raw_body: &str, value: &Value) -> Self { Self { event: event.to_string(), - message: failure_message(value).unwrap_or_else(|| "Codex upstream SSE failed".into()), + message: failure_message(value) + .unwrap_or_else(|| "The upstream response stream failed.".into()), upstream_code: failure_code(value), upstream_message: failure_message(value), upstream_type: failure_type(value), diff --git a/backend/crates/providers/openai/tests/provider/contract/mod.rs b/backend/crates/providers/openai/tests/provider/contract/mod.rs index 7237cd394..4159afb4c 100644 --- a/backend/crates/providers/openai/tests/provider/contract/mod.rs +++ b/backend/crates/providers/openai/tests/provider/contract/mod.rs @@ -145,7 +145,7 @@ async fn account_client_preparation_keeps_ca_read_cause_without_exposing_path() assert!(diagnostic.as_str().contains("OS cause:")); assert!(!diagnostic.as_str().contains("PRIVATE_")); let gateway = gateway_core::error::GatewayError::from_provider(&snapshot); - assert_eq!(gateway.safe_message(), "upstream service is unavailable"); + assert_eq!(gateway.safe_message(), "Upstream service is unavailable."); assert_eq!(gateway.diagnostic(), Some(diagnostic)); } diff --git a/backend/crates/providers/openai/tests/provider/contract/privacy.rs b/backend/crates/providers/openai/tests/provider/contract/privacy.rs index 8513a9274..8ee548f70 100644 --- a/backend/crates/providers/openai/tests/provider/contract/privacy.rs +++ b/backend/crates/providers/openai/tests/provider/contract/privacy.rs @@ -217,6 +217,18 @@ async fn privacy_rejection_is_not_sent_and_cannot_retry_or_rotate() { assert_eq!(error.kind(), ProviderErrorKind::InvalidRequest); assert_eq!(error.send_state(), UpstreamSendState::NotSent); assert!(error.retry_is_prohibited()); + let visible = error.client_visible_upstream_error().unwrap(); + assert_eq!(visible.code(), Some("privacy_policy_rejected")); + assert_eq!(visible.error_type(), Some("invalid_request_error")); + assert_eq!( + visible.message(), + "A gateway privacy rule could not process this request." + ); + assert!(!visible.message().contains("$.input")); + assert_eq!( + error.diagnostic().unwrap().code(), + Some("privacy_policy_rejected") + ); assert!(server.received_requests().await.unwrap().is_empty()); } diff --git a/backend/crates/providers/openai/tests/provider/contract/session_binding.rs b/backend/crates/providers/openai/tests/provider/contract/session_binding.rs index 416b6f159..0106310a5 100644 --- a/backend/crates/providers/openai/tests/provider/contract/session_binding.rs +++ b/backend/crates/providers/openai/tests/provider/contract/session_binding.rs @@ -63,7 +63,10 @@ async fn cyber_policy_session_exclusion_has_actionable_non_retryable_error() { .client_visible_upstream_error() .expect("actionable client message"); assert_eq!(visible.code(), Some("cyber_policy")); - assert!(visible.message().contains("Do not repeatedly retry")); + assert_eq!( + visible.message(), + "No account is available for this session after an upstream cyber policy rejection." + ); assert_eq!(server.received_requests().await.unwrap().len(), 1); // 可见但不属于固定账号范围的候选不能掩盖会话限制 create_account(&store, "acct_subagent_b").await; diff --git a/backend/crates/providers/openai/tests/provider/live.rs b/backend/crates/providers/openai/tests/provider/live.rs index a3aebf5f7..8a026233a 100644 --- a/backend/crates/providers/openai/tests/provider/live.rs +++ b/backend/crates/providers/openai/tests/provider/live.rs @@ -310,6 +310,74 @@ async fn live_sideband_rejects_disabled_account_even_before_scope_refresh() { } } +#[tokio::test] +async fn live_proxy_failures_return_actionable_errors_and_release_the_call_claim() { + use gateway_core::{account::OutboundProxy, live::LiveHangupRequest}; + use tokio::{io::AsyncWriteExt, net::TcpListener}; + + let (provider, store, _upstream) = bootstrap().await; + let proxy = TcpListener::bind("127.0.0.1:0").await.unwrap(); + store.set_egress( + ACCOUNT, + Some(OutboundProxy::parse(&format!("http://{}", proxy.local_addr().unwrap())).unwrap()), + None, + ); + let proxy_task = tokio::spawn(async move { + for _ in 0..3 { + let (mut socket, _) = proxy.accept().await.unwrap(); + let head = read_live_head(&mut socket).await; + assert!(head.starts_with("CONNECT api.openai.com:443 ")); + socket + .write_all(b"HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + } + }); + let gateway = provider.live_gateway().unwrap(); + let key = ClientApiKeyId::new("key_openai_contract").unwrap(); + let allowed = scope(AccountModelAccess::default()); + // 拨号失败释放 claim;第二次仍须到达代理,不能被本地 busy 拦住 + for _ in 0..2 { + let error = gateway + .open_sideband(LiveSidebandRequest { + call_id: CALL, + client_api_key_id: &key, + account_scope: &allowed, + style: LiveSidebandStyle::Live, + protocol_headers: Vec::new(), + subprotocols: Vec::new(), + }) + .await + .unwrap_err(); + assert_eq!(error.kind(), LiveGatewayErrorKind::UpstreamUnavailable); + assert_eq!(error.status(), 503); + assert_eq!( + error.message(), + "The voice connection could not be established." + ); + assert!(!error.message().contains("407")); + assert!(error.body().is_none()); + } + let error = gateway + .hangup(LiveHangupRequest { + call_id: CALL, + client_api_key_id: &key, + content_type: None, + body: Bytes::new(), + protocol_headers: Vec::new(), + }) + .await + .unwrap_err(); + assert_eq!(error.status(), 503); + assert_eq!( + error.message(), + "The voice call request could not be completed." + ); + assert!(!error.message().contains("api.openai.com")); + assert!(error.body().is_none()); + proxy_task.await.unwrap(); +} + #[tokio::test] async fn live_bootstrap_rejects_missing_planned_model_before_upstream_send() { let store = Arc::new(MemoryAccountStore::default()); diff --git a/backend/crates/providers/xai/src/provider/failure.rs b/backend/crates/providers/xai/src/provider/failure.rs index 818977196..7db1d1665 100644 --- a/backend/crates/providers/xai/src/provider/failure.rs +++ b/backend/crates/providers/xai/src/provider/failure.rs @@ -434,19 +434,19 @@ fn selection_failure(error: &GrokSessionSelectorError) -> ProviderError { GrokSessionSelectorError::CapacityUnavailable { retry_after } => ( ProviderErrorKind::AccountCapacityUnavailable, *retry_after, - "account is at its concurrency limit".to_owned(), + "The available accounts are busy.".to_owned(), "account_capacity_busy", ), GrokSessionSelectorError::NoEligibleSession => ( ProviderErrorKind::NoEligibleAccount, None, - "no account is eligible for the requested model".to_owned(), + "No account is available for the requested model.".to_owned(), "no_eligible_account", ), GrokSessionSelectorError::Unavailable(_) => ( ProviderErrorKind::ProviderInfrastructureUnavailable, None, - "account scheduling state is temporarily unreadable".to_owned(), + "Account selection is temporarily unavailable.".to_owned(), "account_selector_unavailable", ), GrokSessionSelectorError::PolicyRejected => { @@ -489,20 +489,20 @@ fn selection_failure(error: &GrokSessionSelectorError) -> ProviderError { pub(super) fn cooling_down_message(retry_after: Option) -> String { match retry_after { Some(retry_after) => format!( - "account is cooling down after an upstream failure; retry in {}s", + "The account is temporarily unavailable after an upstream failure; retry in {} seconds.", retry_after.as_secs().saturating_add(1) ), - None => "account is cooling down after an upstream failure".to_owned(), + None => "The account is temporarily unavailable after an upstream failure.".to_owned(), } } pub(super) fn model_cooling_down_message(retry_after: Option) -> String { match retry_after { Some(retry_after) => format!( - "all eligible accounts are cooling down for this model; retry in {}s", + "All eligible accounts are temporarily unavailable for this model; retry in {} seconds.", retry_after.as_secs().saturating_add(1) ), - None => "all eligible accounts are cooling down for this model".to_owned(), + None => "All eligible accounts are temporarily unavailable for this model.".to_owned(), } } diff --git a/backend/crates/providers/xai/src/transport/canonical.rs b/backend/crates/providers/xai/src/transport/canonical.rs index ebdc44cf4..547796cee 100644 --- a/backend/crates/providers/xai/src/transport/canonical.rs +++ b/backend/crates/providers/xai/src/transport/canonical.rs @@ -1201,7 +1201,11 @@ fn incomplete_finish_reason(response: &Value) -> FinishReason { fn upstream_event_error(value: &Value) -> ProviderError { let code = upstream_error_field(value, "code"); - let error_type = upstream_error_field(value, "type"); + // 顶层 type 是 SSE 事件名,只有错误对象内的 type 才属于上游错误事实 + let error_type = value + .pointer("/error/type") + .or_else(|| value.pointer("/response/error/type")) + .and_then(Value::as_str); let message = upstream_error_field(value, "message"); let kind = if classify_grok_quota_failure(code, error_type, message).is_some() { ProviderErrorKind::QuotaExhausted @@ -1229,12 +1233,7 @@ fn upstream_event_error(value: &Value) -> ProviderError { .and_then(|value| u16::try_from(value).ok()) .filter(|value| (400..=599).contains(value)), code: code.map(str::to_owned), - // 顶层 type 是 SSE 事件名,只有错误对象内的 type 才属于上游错误事实 - error_type: value - .pointer("/error/type") - .or_else(|| value.pointer("/response/error/type")) - .and_then(Value::as_str) - .map(str::to_owned), + error_type: error_type.map(str::to_owned), message: message.map(str::to_owned), }); if let Some(code) = code { diff --git a/backend/crates/providers/xai/src/transport/request/mod.rs b/backend/crates/providers/xai/src/transport/request/mod.rs index c96434c99..b073a1923 100644 --- a/backend/crates/providers/xai/src/transport/request/mod.rs +++ b/backend/crates/providers/xai/src/transport/request/mod.rs @@ -317,19 +317,19 @@ impl fmt::Debug for GrokResponsesRequest { #[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)] pub enum GrokRequestEncodeError { /// 数据面只接受 OpenAI adapter 保留的原始 Responses object - #[error("Grok Build request is missing its OpenAI protocol payload")] + #[error("The OpenAI request payload is missing.")] InvalidProtocolPayload, /// Grok Build 不支持 Codex 的非生成预热语义 - #[error("Grok Build does not support non-generating prewarm requests (generate=false)")] + #[error("Grok Build does not support prewarm requests (generate=false).")] UnsupportedPrewarm, /// JSON 序列化意外失败 #[error("Grok Build request serialization failed")] Serialization, /// Responses 兼容字段无法安全归一化 - #[error("Grok Build request normalization failed")] + #[error("The request could not be converted to the Grok Build format.")] InvalidRequestNormalization, /// 请求中的具体字段无法安全转换为 Grok Build 接受的形态 - #[error("Grok Build request field `{field}` could not be normalized safely")] + #[error("The request field `{field}` could not be converted to the Grok Build format.")] InvalidRequestField { field: &'static str }, } diff --git a/backend/crates/providers/xai/tests/provider/contract/mod.rs b/backend/crates/providers/xai/tests/provider/contract/mod.rs index 41999e026..00b711637 100644 --- a/backend/crates/providers/xai/tests/provider/contract/mod.rs +++ b/backend/crates/providers/xai/tests/provider/contract/mod.rs @@ -3316,7 +3316,7 @@ async fn selection_failures_carry_a_distinguishable_client_error_code() { assert_eq!(transport.calls.load(Ordering::SeqCst), 0); if expected_code == "account_cooling_down" { assert!( - detail.message().contains("retry in 13s"), + detail.message().contains("retry in 13 seconds."), "{}", detail.message() ); @@ -4178,7 +4178,7 @@ async fn provider_should_expose_safe_request_normalization_field_before_selectio detail.error_type() )), Some(( - "Grok Build request field `tools` could not be normalized safely", + "The request field `tools` could not be converted to the Grok Build format.", Some("invalid_request_normalization"), Some("invalid_request_error"), )), diff --git a/backend/crates/providers/xai/tests/transport/canonical.rs b/backend/crates/providers/xai/tests/transport/canonical.rs index 1bfdd35c4..8a6c6c2b2 100644 --- a/backend/crates/providers/xai/tests/transport/canonical.rs +++ b/backend/crates/providers/xai/tests/transport/canonical.rs @@ -1544,36 +1544,52 @@ fn decoder_should_classify_incomplete_reasons_and_preserve_native_wire() { } #[test] -fn decoder_should_classify_failed_event_without_retaining_body() { - let body = concat!( - "event: response.created\n", - "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n", - "event: response.failed\n", - "data: {\"type\":\"response.failed\",\"error\":{\"code\":\"rate_limit_exceeded\",\"type\":\"server_error\",\"message\":\"secret\"}}\n\n", - ); - let error = GrokCanonicalDecoder::new("fallback") - .push(body.as_bytes()) - .expect_err("failed response must surface"); +fn decoder_should_preserve_structured_failure_fields_and_hide_raw_debug() { + for (event, expected_error_type) in [ + ( + serde_json::json!({"type":"response.failed","error":{"code":"rate_limit_exceeded","type":"server_error","message":"secret"}}), + Some("server_error"), + ), + ( + serde_json::json!({"type":"response.failed","response":{"error":{"code":"rate_limit_exceeded","type":"provider_specific_error","message":"secret"}}}), + Some("provider_specific_error"), + ), + ( + serde_json::json!({"type":"response.failed","response":{"error":{"code":"rate_limit_exceeded","message":"secret"}}}), + None, + ), + ( + serde_json::json!({"type":"error","code":"rate_limit_exceeded","message":"secret"}), + None, + ), + ] { + let body = format!( + "event: {}\ndata: {event}\n\n", + event["type"].as_str().unwrap() + ); + let error = GrokCanonicalDecoder::new("fallback") + .push(body.as_bytes()) + .expect_err("failed response must surface"); - assert_eq!( - error.kind(), - gateway_core::error::ProviderErrorKind::RateLimited - ); - assert_eq!( - error.upstream_code().map(|code| code.as_str()), - Some("rate_limit_exceeded") - ); - let visible = error - .client_visible_upstream_error() - .expect("structured upstream message is client-visible"); - assert_eq!(visible.message(), "secret"); - assert_eq!(visible.code(), Some("rate_limit_exceeded")); - assert_eq!(visible.error_type(), Some("server_error")); - let raw: serde_json::Value = - serde_json::from_str(error.raw_upstream_error().unwrap().as_str()).unwrap(); - assert_eq!(raw["error"]["message"], "secret"); - assert_eq!(raw["error"]["code"], "rate_limit_exceeded"); - assert!(!format!("{error:?}").contains("secret")); + assert_eq!( + error.kind(), + gateway_core::error::ProviderErrorKind::RateLimited + ); + assert_eq!( + error.upstream_code().map(|code| code.as_str()), + Some("rate_limit_exceeded") + ); + let visible = error + .client_visible_upstream_error() + .expect("structured upstream message is client-visible"); + assert_eq!(visible.message(), "secret"); + assert_eq!(visible.code(), Some("rate_limit_exceeded")); + assert_eq!(visible.error_type(), expected_error_type); + let raw: serde_json::Value = + serde_json::from_str(error.raw_upstream_error().unwrap().as_str()).unwrap(); + assert_eq!(raw, event); + assert!(!format!("{error:?}").contains("secret")); + } } #[test] diff --git a/deploy/README.md b/deploy/README.md index 78625fec5..20be23dca 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -466,7 +466,8 @@ metrics_exporter = "none" 先备份并区分本地真实账号文件与代理密钥文件,不要直接删除全部登录状态 - 已调用生图但失败:查看服务端账号的凭据、权限、额度和请求错误,不能只凭文本对话成功判断 - `401`:检查代理密钥是否正确、是否启用;Actor 标记不能代替密钥 -- `426`:检查客户端版本及反向代理是否保留版本头,低于管理员设置的最低版本时需要更新客户端。 +- `400` 且错误码为 `client_version_too_old` 或 `client_version_unavailable`:检查客户端版本及反向代理是否保留版本头, + 低于管理员设置的最低版本时需要更新客户端 手机远程控制的版本识别范围见 [版本门禁规则](../docs/api.md#1-鉴权与公共约定) - 地址包含 `5173/dev/v1`:这是开发代理地址,依赖 Vite 服务。日常使用改成后端或 HTTPS 地址; 验证 WebSocket 时直接连接后端,当前 Vite 代理未显式开启 WebSocket 转发 diff --git a/docs/api.md b/docs/api.md index 83ec01e10..0ba505a96 100644 --- a/docs/api.md +++ b/docs/api.md @@ -44,7 +44,7 @@ Client Key 通过账号分组限定路由范围:未绑定分组时可使用全 运行设置可以分别配置 `minCodexDesktopVersion` 与 `minCodexCliVersion`。两者只接受 SemVer,`null` 表示不限制,比较版本时忽略 `+` 后的构建元数据。API 在 Client Key 鉴权成功后识别官方客户端请求头; 适用门禁的客户端没有合法版本,或版本低于对应门槛时,除只读 `/v1/usage` 外的 `/v1/*` HTTP 请求和新 WebSocket -握手在访问上游前返回 `426 Upgrade Required`。 +握手在访问上游前返回 `400 Bad Request`,按不可重试的请求错误处理。 未知客户端保持兼容,不应用版本门禁 `minCodexCliVersion` 检查官方客户端 User-Agent 产品段中的 Core 版本,支持 @@ -64,7 +64,7 @@ ChatGPT 远程控制使用 `(codex_chatgpt_<平台>_remote; <版本>)` 形式的 ```json { "error": { - "message": "Codex CLI 0.151.0 is below the minimum required version 0.152.0. Upgrade Codex CLI and retry.", + "message": "Codex CLI 0.151.0 is outdated. Upgrade to 0.152.0 or later.", "type": "invalid_request_error", "code": "client_version_too_old", "client": "codex_cli", @@ -74,7 +74,8 @@ ChatGPT 远程控制使用 `(codex_chatgpt_<平台>_remote; <版本>)` 形式的 } ``` -因缺失或非法版本被门禁拒绝时,`code` 为 `client_version_unavailable`,`current_version` 为 `null` +因缺失或非法版本被门禁拒绝时,`code` 为 `client_version_unavailable`,`current_version` 为 `null`, +提示检查客户端版本及反向代理是否保留 `User-Agent` 和 `version` 头 ### 管理接口 @@ -1745,7 +1746,8 @@ UA、平台参数及自动 Git 上下文版本由 [OpenAI 上游客户端身份] 执行检查点响应请求取消,步数超限报告「规则执行步数超限」,不以固定墙钟时长判定规则失败 执行失败时,`skip_rule` 撤销本条规则所有步骤在所有副本上的修改并继续,意味着本条脱敏未完成; -`reject_request` 阻止发送并返回本地请求错误,不因该错误自动重试或换号。设置保存时校验所有规则,包含未启用的规则 +`reject_request` 阻止发送并返回 `400` / `privacy_policy_rejected`,提示隐私规则处理失败, +不因该错误自动重试或换号。设置保存时校验所有规则,包含未启用的规则 预览请求为 `{ policy, body, headers, turnMetadata }`,其中 `headers` 为头名称到字符串数组的映射, `turnMetadata` 为编码后的 JSON 字符串或 `null`。正文最多 256 KiB,请求头与独立 metadata 合计最多 256 KiB。 diff --git a/docs/architecture.md b/docs/architecture.md index 65b37278e..d0aced525 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -423,7 +423,7 @@ Client Key 并发占用、账号调度槽位与请求恢复记录使用可续期 会话结束或 Drop 后停止续期;Redis 并发租约丢失或无法在有效期内续期时取消请求,避免失去并发约束后继续执行 Client Key 鉴权完成后,API adapter 从有界请求头识别客户端,Core 使用请求冻结的 `RuntimeSnapshot` -检查最低版本,在进入 Provider 前完成门禁。请求头优先级、远程客户端识别与 `426` 错误合同见[鉴权约定](api.md#1-鉴权与公共约定) +检查最低版本,在进入 Provider 前完成门禁。请求头优先级、远程客户端识别与错误合同见[鉴权约定](api.md#1-鉴权与公共约定) 核心不变量: