diff --git a/docs/technical/【技术方案】Tripo 3D模型Provider集成-2026-09-18.md b/docs/technical/【技术方案】Tripo 3D模型Provider集成-2026-09-18.md index d70bed68a..3e424be90 100644 --- a/docs/technical/【技术方案】Tripo 3D模型Provider集成-2026-09-18.md +++ b/docs/technical/【技术方案】Tripo 3D模型Provider集成-2026-09-18.md @@ -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 直接承接。 diff --git a/server-rs/crates/api-server/src/tripo3d/errors.rs b/server-rs/crates/api-server/src/tripo3d/errors.rs index eb3765fc6..1dc358d7d 100644 --- a/server-rs/crates/api-server/src/tripo3d/errors.rs +++ b/server-rs/crates/api-server/src/tripo3d/errors.rs @@ -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); diff --git a/server-rs/crates/platform-tripo/src/common/client.rs b/server-rs/crates/platform-tripo/src/common/client.rs index 0a7a467cd..dee62080a 100644 --- a/server-rs/crates/platform-tripo/src/common/client.rs +++ b/server-rs/crates/platform-tripo/src/common/client.rs @@ -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, } } diff --git a/server-rs/crates/platform-tripo/src/common/error.rs b/server-rs/crates/platform-tripo/src/common/error.rs index a4c60119f..e3bf269af 100644 --- a/server-rs/crates/platform-tripo/src/common/error.rs +++ b/server-rs/crates/platform-tripo/src/common/error.rs @@ -93,6 +93,11 @@ pub enum TripoError { Request { message: String, status: Option, + /// 是否值得重试。由产生错误的一方按真实分类判定:传输层的超时 / 连接失败、 + /// 「无数据推进」超时、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, 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 for TripoError { fn from(error: tripo3d_sdk::Error) -> Self { match error { @@ -315,10 +403,14 @@ impl From 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 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(), diff --git a/server-rs/crates/platform-tripo/src/common/mod.rs b/server-rs/crates/platform-tripo/src/common/mod.rs index 30424cf3f..4e2eb6330 100644 --- a/server-rs/crates/platform-tripo/src/common/mod.rs +++ b/server-rs/crates/platform-tripo/src/common/mod.rs @@ -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; diff --git a/server-rs/crates/platform-tripo/src/common/types.rs b/server-rs/crates/platform-tripo/src/common/types.rs index 5589f04e7..20836123c 100644 --- a/server-rs/crates/platform-tripo/src/common/types.rs +++ b/server-rs/crates/platform-tripo/src/common/types.rs @@ -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, } }