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 <noreply@openai.com>
This commit is contained in:
Zyy
2026-10-29 01:57:31 +08:00
co-authored by Codex
parent 80f7d53085
commit 958e13c370
91 changed files with 1144 additions and 609 deletions
+21
View File
@@ -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 名称` |
| 更新包校验失败 | `更新包校验失败,请重新下载` |
审查时沿实际出口检查语言、大小写、标点、长度和处理建议;复用现有行为测试核对错误码及敏感信息边界,不为逐条文案建立重复的快照或字符串清单测试
+13 -1
View File
@@ -68,6 +68,7 @@ pub struct AdminError {
kind: AdminErrorKind,
message: String,
source: Option<gateway_core::error::ErrorSource>,
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> {
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]
@@ -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
};
@@ -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: "该服务商不支持隐私策略",
})
}
@@ -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<gateway_core::error::ErrorSource>,
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<gateway_core::error::ErrorSource>) -> 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> {
gateway_core::error::ErrorDetails::capture(
self.source.as_ref(),
None,
self.sensitive_context_redacted,
)
}
#[must_use]
pub const fn kind(&self) -> SystemOperationErrorKind {
self.kind
@@ -116,14 +116,14 @@ pub trait AccountsService: Send + Sync {
&self,
_account_id: &ProviderAccountId,
) -> Result<AccountPersonalInfo, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持个人信息"))
Err(AdminError::invalid("该服务商不支持个人信息查询"))
}
async fn profile_avatar(
&self,
_account_id: &ProviderAccountId,
) -> Result<ProviderProfileAvatar, AdminError> {
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<ProviderResetCredits, AdminError> {
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<ProviderResetCreditResult, AdminError> {
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<AccountUpdateResult, AdminError> {
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::<Result<Vec<_>, _>>()?;
let mut providers = BTreeMap::<
@@ -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> {
@@ -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<ClientBudgetStatus, AdminError> {
@@ -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<CreatedClientKey, AdminError> {
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<ClientKeyMutation, AdminError> {
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 分页游标不合法"))
}
}
@@ -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(())
}
@@ -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,
@@ -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,
@@ -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(())
}
@@ -47,14 +47,14 @@ pub trait SettingsService: Send + Sync {
&self,
_provider: &str,
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持客户端身份配置"))
Err(AdminError::invalid("该服务商不支持客户端身份配置"))
}
async fn preview_client_profile(
&self,
_provider: &str,
_configuration: Option<&gateway_core::account::OpaqueProviderData>,
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持客户端身份配置"))
Err(AdminError::invalid("该服务商不支持客户端身份配置"))
}
async fn load(&self) -> Result<RuntimeSettings, AdminError>;
async fn replace(
@@ -86,7 +86,7 @@ impl DefaultSettingsService {
provider: &str,
) -> Result<std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>, 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| {
@@ -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)
}
@@ -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,
@@ -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),
@@ -16,11 +16,15 @@ use gateway_admin::{
struct RecordingSystemOperations {
target: Mutex<Option<Option<String>>>,
channel: Mutex<Option<gateway_admin::model::system::SystemUpdateChannel>>,
failure: Option<SystemOperationError>,
}
#[async_trait]
impl SystemOperations for RecordingSystemOperations {
async fn version(&self) -> Result<SystemVersion, SystemOperationError> {
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());
@@ -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 {
@@ -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} 字段不合法"),
@@ -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<Value>) {
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<Value>) {
pub fn runtime_unavailable_response() -> (StatusCode, Json<Value>) {
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<Value>) {
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",
)
@@ -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);
@@ -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<Value, GatewayError> {
match name {
"n" | "output_compression" | "partial_images" => serde_json::from_slice::<Number>(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::<bool>(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<Value, GatewayError> {
} 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.",
)
}
@@ -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",
))),
}
@@ -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",
))
})?;
@@ -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(
@@ -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",
)
@@ -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",
)
@@ -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.",
))
}
@@ -137,7 +137,7 @@ async fn stream_failure(response: Response, error_encoder: Option<fn(&Value) ->
raw_message.unwrap_or_else(|| {
status
.canonical_reason()
.unwrap_or("Response stream failed")
.unwrap_or("Response stream failed.")
}),
)
});
@@ -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,
)
@@ -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);
@@ -65,7 +65,7 @@ fn search_operation(body: Bytes, headers: &HeaderMap) -> Result<Operation, Gatew
.map_err(|_| {
GatewayError::new(
GatewayErrorKind::Internal,
"OpenAI protocol identifier is invalid",
"OpenAI protocol identifier is invalid.",
)
})?
.with_context(context);
@@ -101,7 +101,7 @@ impl OpenAiService {
.map_err(|_| {
GatewayError::new(
GatewayErrorKind::ModelNotFound,
"requested model was not found",
"Requested model was not found.",
)
})?;
let previous_response_id = match metadata.continuation() {
@@ -142,7 +142,7 @@ impl OpenAiService {
let provider = ProviderKind::new("openai").map_err(|_| {
GatewayError::new(
GatewayErrorKind::Internal,
"OpenAI provider identifier is invalid",
"OpenAI provider identifier is invalid.",
)
})?;
self.execution
@@ -63,7 +63,7 @@ async fn usage_response(
if Query::<UsageQuery>::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",
)
+9 -9
View File
@@ -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::<serde::de::IgnoredAny>(&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,
@@ -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,
})
);
@@ -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,
@@ -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()
@@ -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] {
@@ -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");
@@ -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 {
@@ -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]
@@ -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");
@@ -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.",
),
}
}
@@ -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 {
@@ -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, GatewayError> {
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.")
})
}
@@ -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."),
}
}
@@ -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 {
@@ -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))
@@ -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"));
}
+19 -18
View File
@@ -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() {
+7 -7
View File
@@ -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.",
},
}
}
@@ -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();
@@ -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"));
+1
View File
@@ -160,6 +160,7 @@ impl HostBundle {
lease: Arc<dyn WorkerLeaderLeasePort>,
diagnostics: Arc<dyn gateway_core::diagnostics::OperationalDiagnostics>,
) -> Result<(), HostError> {
self.system.set_diagnostics(Arc::clone(&diagnostics));
self.workers.start(plan, lease, diagnostics)?;
Ok(())
}
@@ -22,7 +22,7 @@ pub(crate) fn extract_release(
temp_dir: &Path,
) -> Result<ExtractedRelease, OperationError> {
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<Option<PathBuf>, 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<Option<PathBuf>, 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)));
}
@@ -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<PathBuf, OperationError> {
@@ -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("尚未配置管理页面文件目录,无法在线更新"))
}
}
@@ -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<OnceLock<Arc<dyn OperationalDiagnostics>>>);
impl Diagnostics {
pub(super) fn bind(&self, diagnostics: Arc<dyn OperationalDiagnostics>) {
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<String>,
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;
}));
}
}
}
@@ -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<String> {
fn sha256_file(path: &Path) -> Result<String, OperationError> {
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;
}
@@ -16,6 +16,16 @@ pub(super) fn upstream(message: impl Into<String>) -> 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<String>) -> OperationError {
SystemOperationError::new(SystemOperationErrorKind::Internal, message)
}
@@ -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<String> {
@@ -2,6 +2,7 @@
mod archive;
mod config;
mod diagnostics;
mod download;
mod error;
mod events;
@@ -44,6 +44,7 @@ pub struct ProcessSystemOperations {
operation_lock: Arc<AsyncMutex<Option<OperationFileLock>>>,
release_cache: Arc<ReleaseCache>,
running_files: Arc<Result<ReleaseFiles, OperationError>>,
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<dyn gateway_core::diagnostics::OperationalDiagnostics>,
) {
self.diagnostics.bind(diagnostics);
}
fn lock_operation(&self) -> Result<OwnedMutexGuard<Option<OperationFileLock>>, 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<Arc<[u8]>, 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);
}
}
}
@@ -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))
}
@@ -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<T: serde::de::DeserializeOwned>(
.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::<T>()
.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<String>) -> Result<String, OperationError> {
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(&current, &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
}
@@ -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<Self, OperationError> {
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<bool, OperationError> {
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<PersistedState, OperationError> {
@@ -410,8 +417,8 @@ fn read_persisted(path: &Path) -> Result<PersistedState, OperationError> {
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 {
@@ -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<PathBuf, OperationError> {
.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<String>, action: &'static str, result
fn error_with_rollback(
context: &'static str,
error: impl fmt::Display,
error: impl Error + Send + Sync + 'static,
rollback_errors: Vec<String>,
) -> 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("; "))),
)
}
@@ -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::<Vec<_>>().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;
}
}
@@ -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<Vec<gateway_core::diagnostics::OperationalFailure>>,
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 {
@@ -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());
@@ -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::<serde_json::Error>());
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();
@@ -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,
@@ -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,
@@ -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)),
);
@@ -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::<bool, _>("enabled") {
return Err(GatewayError::new(
GatewayErrorKind::PolicyDenied,
"client API key is disabled",
"Client API key is disabled.",
));
}
let parse = |field: &str| -> Result<Decimal, GatewayError> {
@@ -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")
}
@@ -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<ProviderDiagnostic> {
pub(super) fn client_diagnostic(error: &CodexClientError) -> Option<ProviderDiagnostic> {
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,
@@ -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();
@@ -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),
@@ -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),
@@ -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));
}
@@ -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());
}
@@ -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;
@@ -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());
@@ -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<Duration>) -> 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<Duration>) -> 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(),
}
}
@@ -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 {
@@ -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 },
}
@@ -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"),
)),
@@ -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]
+2 -1
View File
@@ -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 转发
+6 -4
View File
@@ -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。
+1 -1
View File
@@ -423,7 +423,7 @@ Client Key 并发占用、账号调度槽位与请求恢复记录使用可续期
会话结束或 Drop 后停止续期;Redis 并发租约丢失或无法在有效期内续期时取消请求,避免失去并发约束后继续执行
Client Key 鉴权完成后,API adapter 从有界请求头识别客户端,Core 使用请求冻结的 `RuntimeSnapshot`
检查最低版本,在进入 Provider 前完成门禁。请求头优先级、远程客户端识别与 `426` 错误合同见[鉴权约定](api.md#1-鉴权与公共约定)
检查最低版本,在进入 Provider 前完成门禁。请求头优先级、远程客户端识别与错误合同见[鉴权约定](api.md#1-鉴权与公共约定)
核心不变量: