From 2330a25172a189da565c17967d4c2eb4594cc2bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 17:59:26 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=A0=E8=BE=93=E5=88=A4=E6=8D=AE=E8=A1=A5?= =?UTF-8?q?=E4=B8=8A=20is=5Fbody=EF=BC=8C=E5=B9=B6=E6=8A=8A=E6=88=AA?= =?UTF-8?q?=E6=96=AD=E4=BD=93=E7=9A=84=E5=BD=92=E5=9B=A0=E5=86=99=E6=B8=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server-rs/crates/platform-tripo/src/common/error.rs:transport_error_is_transient 补上 is_body(),与 TripoError::Request.retryable 文档列举的瞬时类型对齐 - server-rs/crates/platform-tripo/src/common/error.rs:注释写明「传输中途截断的响应体在 reqwest 里落在 Decode」这一实测结论,说明为何不把 Decode 判成可重试 - server-rs/crates/platform-tripo/src/common/error.rs:补用例用截断响应体锁住 Decode 不判可重试 --- .../crates/platform-tripo/src/common/error.rs | 60 ++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) 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` 是否值得重试。