diff --git a/server-rs/crates/platform-tripo/src/common/error.rs b/server-rs/crates/platform-tripo/src/common/error.rs index 95cc5f91b..551ef1e07 100644 --- a/server-rs/crates/platform-tripo/src/common/error.rs +++ b/server-rs/crates/platform-tripo/src/common/error.rs @@ -195,6 +195,33 @@ mod tests { } } + /// 声明 64 字节、只给 4 字节就断开:读 body 会得到传输中断类错误。 + async fn spawn_truncated_body_server() -> std::net::SocketAddr { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("mock server 必须能监听"); + let addr = listener.local_addr().expect("mock server 必须有地址"); + tokio::spawn(async move { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + let mut request = [0u8; 1024]; + let _ = socket.read(&mut request).await; + let head = "HTTP/1.1 200 OK\r\n\ + Content-Type: model/gltf-binary\r\n\ + Content-Length: 64\r\n\ + \r\n"; + if socket.write_all(head.as_bytes()).await.is_err() { + return; + } + let _ = socket.write_all(b"glTF").await; + let _ = socket.flush().await; + }); + addr + } + /// 是否可重试不再由「有没有 status」推断:同一个 `None` 既可能是瞬时传输失败, /// 也可能是「响应体格式不对」这种再试一次也一样的结果。 #[test] @@ -253,6 +280,28 @@ mod tests { .expect_err("非法地址必须失败"); assert!(!transport_error_is_transient(&malformed), "{malformed}"); assert!(!sdk_request_is_retryable(None, Some(&malformed))); + + // 传输中途截断的响应体:reqwest 归到 Decode(见上面的说明),这里刻意不判可重试, + // 免得「提交请求的响应体解码失败」再发一次造成重复下单。 + let addr = spawn_truncated_body_server().await; + let response = reqwest::Client::new() + .get(format!("http://{addr}/a.glb")) + .send() + .await + .expect("响应头必须正常返回"); + let truncated = response + .bytes() + .await + .expect_err("声明 64 字节却只给 4 字节必须失败"); + assert!( + truncated.is_decode() && !truncated.is_body(), + "截断的响应体应落在 Decode 而不是 Body:{truncated:?}" + ); + assert!( + !transport_error_is_transient(&truncated), + "落在 Decode 的截断体不判可重试(提交请求重发会重复下单):{truncated}" + ); + assert!(!sdk_request_is_retryable(None, Some(&truncated))); } #[test] @@ -376,9 +425,16 @@ pub(crate) fn http_status_is_transient(status: u16) -> bool { } /// 传输层失败是否瞬时。`is_connect` 覆盖连接被拒 / 重置与握手阶段失败、`is_timeout` -/// 覆盖连接与读写超时;其余(URL 非法、构建期配置错误)重试也是同一个结果。 +/// 覆盖连接与读写超时、`is_body` 覆盖块读取阶段的响应体传输中断 —— 对齐 +/// `TripoError::Request.retryable` 文档里列举的瞬时类型;其余(URL 非法、 +/// 构建期配置错误、响应体格式 / 解码失败)重试也是同一个结果。 +/// +/// 注意:传输中途截断的响应体在 reqwest 里落在 `Decode`(source 是 +/// `hyper::Error(Body, IncompleteBody)`),与真正的解码失败在 `reqwest::Error` 层面无法 +/// 区分,因此这里**不**把 `Decode` 判成可重试 —— 提交请求的响应体解码失败再发一次可能 +/// 重复下单。产物下载路径有单独的 `body_read_error`,由那条路径自己标记可重试。 pub(crate) fn transport_error_is_transient(error: &reqwest::Error) -> bool { - error.is_timeout() || error.is_connect() + error.is_timeout() || error.is_connect() || error.is_body() } /// SDK 的 `Error::Request` 是否值得重试。