可重试性改为显式判定,不再由 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:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user