传输判据补上 is_body,并把截断体的归因写清

- 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 不判可重试
This commit is contained in:
2026-09-24 17:59:26 +08:00
parent ebce5f9304
commit 2330a25172
@@ -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` 是否值得重试。