mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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:
@@ -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 名称` |
|
||||
| 更新包校验失败 | `更新包校验失败,请重新下载` |
|
||||
|
||||
审查时沿实际出口检查语言、大小写、标点、长度和处理建议;复用现有行为测试核对错误码及敏感信息边界,不为逐条文案建立重复的快照或字符串清单测试
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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(¤t, &target, channel) {
|
||||
return Err(conflict(
|
||||
"所选通道不允许此目标,或目标涉及跨实验线、跨大版本、降级",
|
||||
@@ -494,13 +494,13 @@ impl SystemUpdateConfig {
|
||||
return Some("检查更新需要配置 CPR_UPDATE_REPOSITORY".to_owned());
|
||||
};
|
||||
if let Err(error) = validate_repository(repository) {
|
||||
return Some(error.to_string());
|
||||
return Some(error.message().to_owned());
|
||||
}
|
||||
if let Err(error) = validate_api_base(&self.github_api_base) {
|
||||
return Some(error);
|
||||
}
|
||||
if let Err(error) = self.web_dist_dir() {
|
||||
return Some(error.to_string());
|
||||
return Some(error.message().to_owned());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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。
|
||||
|
||||
@@ -423,7 +423,7 @@ Client Key 并发占用、账号调度槽位与请求恢复记录使用可续期
|
||||
会话结束或 Drop 后停止续期;Redis 并发租约丢失或无法在有效期内续期时取消请求,避免失去并发约束后继续执行
|
||||
|
||||
Client Key 鉴权完成后,API adapter 从有界请求头识别客户端,Core 使用请求冻结的 `RuntimeSnapshot`
|
||||
检查最低版本,在进入 Provider 前完成门禁。请求头优先级、远程客户端识别与 `426` 错误合同见[鉴权约定](api.md#1-鉴权与公共约定)
|
||||
检查最低版本,在进入 Provider 前完成门禁。请求头优先级、远程客户端识别与错误合同见[鉴权约定](api.md#1-鉴权与公共约定)
|
||||
|
||||
核心不变量:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user