可重试性改为显式判定,不再由 status 缺省推断

- TripoError::Request 增加 retryable 字段:由产生错误的一方按真实分类判定
- 传输层失败按 reqwest 的 is_timeout / is_connect 分类(新增 transport_error_is_transient,
  以及状态集合 http_status_is_transient)
- SDK 的 Error::Request 只在带瞬时状态码、或底层 reqwest 错误确实瞬时时可重试;
  source 为空(空 body / 格式错误 / 缺 data 字段)不再重试
- 产物客户端构造失败标为不可重试;无数据推进、body 读取失败与长度不一致标为可重试
- 新增四组用例:显式标记、状态集合、SDK 分类,以及用真实 reqwest 错误(连接被拒 vs 非法地址)验证分类
- 同步技术方案:写明「可重试性由产生错误处判定」的口径
This commit is contained in:
2026-09-23 17:40:18 +08:00
parent f6f41f22c3
commit 42b4315b4f
6 changed files with 121 additions and 10 deletions
@@ -44,7 +44,7 @@ TODO:图生输入当前由 api-server 自己读站内对象的字节再上传
`get_task``TripoTaskHandle` 做单次查询,返回通用 `TripoTaskSnapshot`,由 `task_type` 决定 `output` 的具体 variant;adapter 不做轮询、不阻塞等待。
`download_model`(及预览图的 `download_rendered_image`)接受 `TripoTaskSnapshot` 与调用方给的体积上限 `max_bytes`,先确认快照确实处于完成态,再从严格 endpoint 结果中取得已校验的产物地址,由 provider 自己的无鉴权 reqwest client 打开签名 URL,读完整个 body 后返回 `TripoArtifactBytes``url``content_type``content_length``bytes``filename(name)`)。流式句柄 `TripoDownloadedArtifact``next_chunk()`)保留为内部实现:**重试必须包住整个 body** —— 只重试「拿到响应头」这一步的时候,几十 MB 的字节其实是在之后才传输的,一次 CDN 抖动就会毁掉一次已经扣费、provider 任务也跑完的生成。因此每次尝试都重新取响应头并整体重下(不做断点续传,签名地址会过期),body 读取失败与长度不一致都归一成可重试的传输错误,重试上限与退避沿用 `TripoSettings::retries``filename(name)` 的扩展名来自远端地址,只接受短的 ASCII 字母数字,其余退回 `glb`,避免远端地址里的 `%2F` 解码后拼出跨目录路径。每次读取都校验实际接收字节数与 `Content-Length`,读到 `max_bytes` 之上直接按输出违约失败(不重试):宁可失败退款也不要把 api-server 内存打满。产物下载不设总超时:几十 MB 的流只要还在出数据就不该被判失败,重下只发生在传输真的断了的时候,因此下载链路只按「无数据推进」判超时,预算取 `TripoSettings::request_timeout`(连接用 `connect_timeout`,响应体用 `read_timeout`,响应头之前由显式的 `tokio::time::timeout` 兜住),超过预算没有数据推进即按传输失败返回;SDK 保持第三方原样,不承担产物下载。smoke example 一次写入完整字节;未来接入 OSS 时应把同一数据流直接送入 OSS 分片上传,不经过完整内存缓冲。
`download_model`(及预览图的 `download_rendered_image`)接受 `TripoTaskSnapshot` 与调用方给的体积上限 `max_bytes`,先确认快照确实处于完成态,再从严格 endpoint 结果中取得已校验的产物地址,由 provider 自己的无鉴权 reqwest client 打开签名 URL,读完整个 body 后返回 `TripoArtifactBytes``url``content_type``content_length``bytes``filename(name)`)。流式句柄 `TripoDownloadedArtifact``next_chunk()`)保留为内部实现:**重试必须包住整个 body** —— 只重试「拿到响应头」这一步的时候,几十 MB 的字节其实是在之后才传输的,一次 CDN 抖动就会毁掉一次已经扣费、provider 任务也跑完的生成。因此每次尝试都重新取响应头并整体重下(不做断点续传,签名地址会过期),body 读取失败与长度不一致都归一成可重试的传输错误,重试上限与退避沿用 `TripoSettings::retries`**可重试性由产生错误的一方显式判定,不再由「`status` 是不是 `None`」推断**:传输层的超时 / 连接失败、「无数据推进」超时、body 提前结束都可重试;客户端构造失败、响应体格式错误(空 body / 缺 `data` 字段)、DNS 与 TLS 之外的永久失败不可重试 —— 只看 `status.is_none()` 会把这两类混在一起,把故障拖到重试耗尽才暴露。`filename(name)` 的扩展名来自远端地址,只接受短的 ASCII 字母数字,其余退回 `glb`,避免远端地址里的 `%2F` 解码后拼出跨目录路径。每次读取都校验实际接收字节数与 `Content-Length`,读到 `max_bytes` 之上直接按输出违约失败(不重试):宁可失败退款也不要把 api-server 内存打满。产物下载不设总超时:几十 MB 的流只要还在出数据就不该被判失败,重下只发生在传输真的断了的时候,因此下载链路只按「无数据推进」判超时,预算取 `TripoSettings::request_timeout`(连接用 `connect_timeout`,响应体用 `read_timeout`,响应头之前由显式的 `tokio::time::timeout` 兜住),超过预算没有数据推进即按传输失败返回;SDK 保持第三方原样,不承担产物下载。smoke example 一次写入完整字节;未来接入 OSS 时应把同一数据流直接送入 OSS 分片上传,不经过完整内存缓冲。
TODO:等待上游 `tripo-rust-sdk` 提供原生 artifact stream API 后,删除 provider-side reqwest 下载器,改由 SDK stream 直接承接。
@@ -162,6 +162,7 @@ mod tests {
let error = map_provider_error(TripoError::Request {
message: "transport".to_string(),
status: None,
retryable: true,
});
assert_eq!(error.status_code(), StatusCode::BAD_GATEWAY);
}
@@ -267,6 +268,7 @@ mod tests {
TripoError::Request {
message: "secret-request-message".to_string(),
status: None,
retryable: true,
},
),
];
@@ -313,6 +315,7 @@ mod tests {
let unknown = map_request_error(Model3dRequestError::Provider(TripoError::Request {
message: "transport".to_string(),
status: None,
retryable: false,
}));
assert_eq!(unknown.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(detail(&unknown, "field"), None);
@@ -5,7 +5,8 @@ use tripo3d_sdk::TripoClient;
use super::{
TripoArtifactBytes, TripoDownloadedArtifact, TripoError, TripoSettings, TripoTaskHandle,
TripoTaskOutput, TripoTaskSnapshot, TripoUrl, map_task, validate_task_id,
TripoTaskOutput, TripoTaskSnapshot, TripoUrl, http_status_is_transient, map_task,
transport_error_is_transient, validate_task_id,
};
pub struct TripoProviderClient {
@@ -35,6 +36,8 @@ impl TripoProviderClient {
.map_err(|error| TripoError::Request {
message: format!("failed to build artifact download client: {error}"),
status: None,
// 构建期失败是配置 / 环境问题:重试不会变好。
retryable: false,
})?;
Ok(Self {
client: TripoClient::new(settings.client_options()).map_err(TripoError::from)?,
@@ -137,6 +140,9 @@ impl TripoProviderClient {
transport_error_source(&error),
),
status: None,
// 传输失败只在确实是瞬时(连接抖动 / 超时)时重试:DNS / TLS /
// 地址配置这类失败再试一次也是同一个结果,早点暴露比拖着强。
retryable: transport_error_is_transient(&error),
})?;
let status = response.status();
if !status.is_success() {
@@ -148,6 +154,7 @@ impl TripoProviderClient {
url.redacted(),
),
status: Some(status.as_u16()),
retryable: http_status_is_transient(status.as_u16()),
});
}
@@ -178,6 +185,8 @@ fn artifact_stall_error(task_id: &str, stall_timeout: Duration) -> TripoError {
"artifact download stalled for task {task_id}: no data received within {stall_timeout:?}"
),
status: None,
// 「无数据推进」是典型的瞬时问题:换条连接重下往往就好了。
retryable: true,
}
}
@@ -93,6 +93,11 @@ pub enum TripoError {
Request {
message: String,
status: Option<u16>,
/// 是否值得重试。由产生错误的一方按真实分类判定:传输层的超时 / 连接失败、
/// 「无数据推进」超时、body 传输中断都是瞬时的,而客户端构造失败、DNS / TLS
/// 之外的配置问题、响应体格式错误这类「再试一次也一样」的失败不是 ——
/// 只看 `status.is_none()` 会把两者混在一起。
retryable: bool,
},
TaskFailure {
task_id: String,
@@ -137,7 +142,9 @@ impl fmt::Display for TripoError {
f,
"Tripo API error code={code} status={status:?} message={message:?} suggestion={suggestion:?}"
),
Self::Request { message, status } => {
Self::Request {
message, status, ..
} => {
write!(f, "Tripo request error status={status:?}: {message}")
}
Self::TaskFailure {
@@ -181,6 +188,66 @@ mod tests {
}
}
/// 是否可重试不再由「有没有 status」推断:同一个 `None` 既可能是瞬时传输失败,
/// 也可能是「响应体格式不对」这种再试一次也一样的结果。
#[test]
fn request_retryability_is_explicit() {
let transient = TripoError::Request {
message: "connection reset by peer".to_string(),
status: None,
retryable: true,
};
assert!(transient.is_retryable());
let permanent = TripoError::Request {
message: "malformed response: expected value at line 1 column 1".to_string(),
status: None,
retryable: false,
};
assert!(!permanent.is_retryable(), "响应体格式错误不能重试");
}
#[test]
fn http_status_transience_matches_the_retry_set() {
for status in [408u16, 425, 429, 500, 502, 599] {
assert!(http_status_is_transient(status), "{status} 属于瞬时状态");
}
for status in [200u16, 400, 404, 409, 422, 600] {
assert!(!http_status_is_transient(status), "{status} 不属于瞬时状态");
}
}
#[test]
fn sdk_request_retryability_needs_a_transient_cause() {
assert!(sdk_request_is_retryable(Some(503), None));
assert!(!sdk_request_is_retryable(Some(404), None));
assert!(
!sdk_request_is_retryable(None, None),
"空 body / 格式错误这类没有底层传输错误的 Request 不能重试"
);
}
/// 底层 reqwest 分类直接决定可重试性:连不上(连接被拒 / 握手失败)可重试,
/// 构建期 / 地址类失败不可重试。
#[tokio::test]
async fn transport_transience_follows_the_reqwest_classification() {
let refused = reqwest::Client::new()
.get("http://127.0.0.1:1")
.send()
.await
.expect_err("连不上的地址必须失败");
assert!(transport_error_is_transient(&refused), "{refused}");
assert!(sdk_request_is_retryable(None, Some(&refused)));
let malformed = reqwest::Client::new()
.get("http://")
.send()
.await
.expect_err("非法地址必须失败");
assert!(!transport_error_is_transient(&malformed), "{malformed}");
assert!(!sdk_request_is_retryable(None, Some(&malformed)));
}
#[test]
fn error_source_chain_skips_the_outermost_error() {
let error = ChainError {
@@ -287,14 +354,35 @@ impl TripoError {
pub fn is_retryable(&self) -> bool {
match self {
Self::Api { status, .. } => matches!(status, Some(408 | 425 | 429 | 500..=599)),
Self::Request { status, .. } => {
status.is_none() || matches!(status, Some(408 | 425 | 429 | 500..=599))
}
Self::Request { retryable, .. } => *retryable,
_ => false,
}
}
}
/// HTTP 状态是否属于瞬时:与 SDK 的默认重试状态集合一致(408 / 425 / 429 / 5xx)。
pub(crate) fn http_status_is_transient(status: u16) -> bool {
matches!(status, 408 | 425 | 429 | 500..=599)
}
/// 传输层失败是否瞬时。`is_connect` 覆盖连接被拒 / 重置与握手阶段失败、`is_timeout`
/// 覆盖连接与读写超时;其余(URL 非法、构建期配置错误)重试也是同一个结果。
pub(crate) fn transport_error_is_transient(error: &reqwest::Error) -> bool {
error.is_timeout() || error.is_connect()
}
/// SDK 的 `Error::Request` 是否值得重试。
///
/// 有状态码时按状态判;没有状态码时只有底层 reqwest 错误确实瞬时才算 ——
/// SDK 里 `source: None` 的 `Request` 是「响应体为空 / 格式不对 / 缺 `data` 字段」,
/// 再试一次也是同一个结果(`is_timeout` / `is_connect` 的传输失败 SDK 自己已经重试过一轮)。
fn sdk_request_is_retryable(status: Option<u16>, source: Option<&reqwest::Error>) -> bool {
match status {
Some(status) => http_status_is_transient(status),
None => source.is_some_and(transport_error_is_transient),
}
}
impl From<tripo3d_sdk::Error> for TripoError {
fn from(error: tripo3d_sdk::Error) -> Self {
match error {
@@ -315,10 +403,14 @@ impl From<tripo3d_sdk::Error> for TripoError {
status,
body,
source,
} => Self::Request {
message: request_failure_message(message, body.as_deref(), source.as_ref()),
status,
},
} => {
let retryable = sdk_request_is_retryable(status, source.as_ref());
Self::Request {
message: request_failure_message(message, body.as_deref(), source.as_ref()),
status,
retryable,
}
}
tripo3d_sdk::Error::Task { task } => Self::TaskFailure {
task_id: task.task_id.clone(),
status: task.status.to_string(),
@@ -330,6 +422,8 @@ impl From<tripo3d_sdk::Error> for TripoError {
} => Self::Request {
message: format!("unexpected SDK timeout after {timeout_ms}ms for task {task_id}"),
status: None,
// 等待超时是瞬时的:重试一次可能就等到了。
retryable: true,
},
tripo3d_sdk::Error::Io(error) => Self::Sdk {
message: error.to_string(),
@@ -10,6 +10,7 @@ mod wire;
pub use client::TripoProviderClient;
pub use config::TripoSettings;
pub use error::{TripoError, TripoField, TripoValidationReason};
pub(crate) use error::{http_status_is_transient, transport_error_is_transient};
pub(crate) use extra::extra_fields;
pub(crate) use mapping::map_task;
pub(crate) use types::TripoDownloadedArtifact;
@@ -249,12 +249,16 @@ impl TripoArtifactBytes {
}
/// 响应体读取失败:统一成可重试的传输错误,并把 HTTP 状态留在文案里供排障。
///
/// `chunk()` 的失败只可能是传输层问题(超时 / 连接中断 / body 被截断),换条连接
/// 整段重下才有机会拿到完整字节,因此固定按可重试标记。
fn body_read_error(task_id: &str, status: u16, detail: String) -> TripoError {
TripoError::Request {
message: format!(
"failed to read artifact response body for task {task_id} (HTTP {status}): {detail}"
),
status: None,
retryable: true,
}
}