provider 的 4 字头不再一律按「你的参数不对」回客户端

- api-server tripo3d/errors.rs:401/403 按上游凭据或权限不可用(502,`tripo-api-unauthorized`)、404 按上游找不到(502,`tripo-api-not-found`)、409 按上游冲突(409,`tripo-api-conflict`)、其余 4 字头按上游拒绝(502,`tripo-api-refused`);只有 400 / 422 继续回 400 `tripo-api-rejected`
- 文件头部说明补上这条分档口径
- 用例:`provider_client_side_4xx_stays_bad_request` 覆盖 400 / 422;新增 `provider_non_parameter_4xx_is_not_reported_as_bad_request` 覆盖 401/403/404/409/402 的状态码、reason 与「不回传上游原文」
This commit is contained in:
2026-09-24 19:53:52 +08:00
parent 14e72379fc
commit 6e081676f0
@@ -1,6 +1,8 @@
//! Tripo 生成对外错误的统一映射。
//!
//! 映射目标只有三档:参数问题 400、幂等冲突 409、provider / 配置问题 5xx。
//! 4 字头里只有 400 / 422 是「你的请求被拒」:401 / 403 是密钥或权限没配好、404 是找不到、
//! 409 是上游冲突,都不按参数问题回 400,否则客户端会去改一个改不动的地方。
//! 任何分支都不把 provider task id、带签名的临时地址或 SDK 类型带进响应。
use axum::http::StatusCode;
@@ -79,13 +81,51 @@ pub(crate) fn map_provider_error(error: TripoError) -> AppError {
"tripo-api-retry-later",
"3D 生成服务暂时无法处理该请求,请稍后重试。".to_string(),
),
TripoError::Api { status, .. } if matches!(status, Some(400..=499)) => (
// 401 / 403 是 provider 侧密钥或调用权限没配好,不是调用方参数写错:
// 混进 400 会让客户端把服务端配置故障当成自己的问题去改参数。
TripoError::Api {
status: Some(401 | 403),
..
} => (
StatusCode::BAD_GATEWAY,
"tripo-api-unauthorized",
"3D 生成服务的调用凭据或权限不可用,请联系支持。".to_string(),
),
TripoError::Api {
status: Some(404), ..
} => (
StatusCode::BAD_GATEWAY,
"tripo-api-not-found",
"3D 生成服务未找到请求的资源。".to_string(),
),
TripoError::Api {
status: Some(409), ..
} => (
StatusCode::CONFLICT,
"tripo-api-conflict",
"3D 生成服务报告请求冲突,请稍后重试。".to_string(),
),
// 只有 400 / 422 真的是「参数被拒」,provider 的 code / message / suggestion 是原文
// 转述的 HTTP 错误体,没有做过 URL 清洗:回给客户端可能带出带签名的地址等内部信息,
// 因此只给固定文案。
TripoError::Api {
status: Some(400 | 422),
..
} => (
StatusCode::BAD_REQUEST,
"tripo-api-rejected",
// provider 的 code / message / suggestion 是原文转述的 HTTP 错误体,没有做过
// URL 清洗:回给客户端可能带出带签名的地址等内部信息,因此只给固定文案。
"3D 生成请求被 provider 拒绝,请检查参数后重试。".to_string(),
),
// 其余 4 字头(402 余额、405 方法、412 前置条件……)既不是调用方参数写错,也不是
// 瞬时状态:按上游错误上报,保留原文之外的固定文案。
TripoError::Api {
status: Some(400..=499),
..
} => (
StatusCode::BAD_GATEWAY,
"tripo-api-refused",
"3D 生成服务暂时拒绝了该请求,请稍后重试。".to_string(),
),
// 以下都是 5xx:完整错误只进日志。`TripoError` 的 Display 会带上 provider task id
// 直接回给客户端会破坏本文件「不暴露 task id」的口径,因此对外只给固定文案。
TripoError::TaskFailure { .. } => {
@@ -189,13 +229,41 @@ mod tests {
#[test]
fn provider_client_side_4xx_stays_bad_request() {
let error = map_provider_error(TripoError::Api {
code: 1002,
message: Some("bad parameter".to_string()),
suggestion: None,
status: Some(400),
});
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
for status in [400u16, 422] {
let error = map_provider_error(TripoError::Api {
code: 1002,
message: Some("bad parameter".to_string()),
suggestion: None,
status: Some(status),
});
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST, "{status}");
assert_eq!(detail(&error, "reason"), Some(json!("tripo-api-rejected")));
}
}
/// 401 / 403 / 404 / 409 与其余 4 字头都不是「调用方参数不对」:按上游 / 冲突上报,
/// 且各自有独立 reason,运维能从响应里看出是配置、找不到还是冲突。
#[test]
fn provider_non_parameter_4xx_is_not_reported_as_bad_request() {
for (status, expected_status, expected_reason) in [
(401u16, StatusCode::BAD_GATEWAY, "tripo-api-unauthorized"),
(403, StatusCode::BAD_GATEWAY, "tripo-api-unauthorized"),
(404, StatusCode::BAD_GATEWAY, "tripo-api-not-found"),
(409, StatusCode::CONFLICT, "tripo-api-conflict"),
(402, StatusCode::BAD_GATEWAY, "tripo-api-refused"),
] {
let error = map_provider_error(TripoError::Api {
code: 1003,
message: Some("secret-upstream-body".to_string()),
suggestion: None,
status: Some(status),
});
assert_eq!(error.status_code(), expected_status, "status {status}");
assert_eq!(detail(&error, "reason"), Some(json!(expected_reason)));
let serialized = serde_json::to_string(error.details().expect("details 必须存在"))
.expect("可序列化");
assert!(!serialized.contains("secret-upstream-body"), "{serialized}");
}
}
/// 本模块头部承诺「任何分支都不把 provider task id 带进响应」,502 分支必须守住。