SDK 请求错误不再丢弃响应体与底层错误链

- 映射 tripo3d_sdk::Error::Request 时带上 body 与 source 字段
- 响应体截断到 512 字符,疑似带 http(s) 地址的响应体整体丢弃
- 只取错误链底层文本,避免 reqwest 顶层 Display 带出完整签名 URL
- 补错误链收集、响应体过滤与截断的用例
This commit is contained in:
2026-09-22 16:55:16 +08:00
parent 3ab7b386f1
commit aa6e3b8d79
@@ -150,6 +150,131 @@ impl fmt::Display for TripoError {
impl std::error::Error for TripoError {}
#[cfg(test)]
mod tests {
use super::*;
/// 只有 source 链的假错误,用来验证错误链文本的收集口径。
#[derive(Debug)]
struct ChainError {
text: &'static str,
source: Option<Box<dyn std::error::Error + 'static>>,
}
impl fmt::Display for ChainError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.text)
}
}
impl std::error::Error for ChainError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.source.as_deref()
}
}
#[test]
fn error_source_chain_skips_the_outermost_error() {
let error = ChainError {
text: "error sending request for url (https://example.com/a?sign=secret)",
source: Some(Box::new(ChainError {
text: "connection reset by peer",
source: Some(Box::new(ChainError {
text: "os error 104",
source: None,
})),
})),
};
assert_eq!(
error_source_chain(&error),
vec![
"connection reset by peer".to_string(),
"os error 104".to_string()
]
);
}
#[test]
fn body_snippet_drops_blank_and_url_bearing_bodies() {
assert_eq!(body_snippet(" "), None);
assert_eq!(
body_snippet("<html>failed to fetch https://cdn.example.com/a.glb?sig=secret</html>"),
None
);
assert_eq!(
body_snippet(" {\"code\": 1001, \"message\": \"bad params\"} "),
Some("{\"code\": 1001, \"message\": \"bad params\"}".to_string())
);
}
#[test]
fn body_snippet_is_truncated() {
let body = "x".repeat(REQUEST_BODY_SNIPPET_MAX_CHARS + 10);
let snippet = body_snippet(&body).expect("超长响应体仍应给出截断摘要");
assert_eq!(snippet.chars().count(), REQUEST_BODY_SNIPPET_MAX_CHARS + 1);
assert!(snippet.ends_with('…'));
}
}
/// 附加到 provider 请求错误上的响应体上限:这段文案会被持久化进任务错误消息,
/// 不能把整页 HTML 原样塞进去。
const REQUEST_BODY_SNIPPET_MAX_CHARS: usize = 512;
/// provider 请求失败的可诊断文案。
///
/// SDK 的 `Error::Request` 除了 `message` 还带响应体与底层 `reqwest::Error`,这里一并收进
/// 文案,重试判定与排障就不必回 SDK 里另找:
/// - 只取错误链上**底层**的文本(hyper / rustls 层,不含地址);`reqwest::Error` 自身的
/// `Display` 会带上完整 URL(含签名 query),因此不把它写进文案。
/// - 响应体只在看起来不含 `http(s)://` 时附加,避免把带签名的地址写进会被持久化的消息。
fn request_failure_message(
message: String,
body: Option<&str>,
source: Option<&reqwest::Error>,
) -> String {
let mut text = message;
if let Some(snippet) = body.and_then(body_snippet) {
text.push_str(&format!("; body={snippet}"));
}
if let Some(source) = source {
let chain = error_source_chain(source);
if !chain.is_empty() {
text.push_str(&format!("; cause={}", chain.join(" <- ")));
}
}
text
}
/// 去掉首尾空白、截断,并挡掉疑似带地址的响应体。
fn body_snippet(body: &str) -> Option<String> {
let body = body.trim();
if body.is_empty() || body.contains("http://") || body.contains("https://") {
return None;
}
let mut chars = body.chars();
let mut snippet: String = chars
.by_ref()
.take(REQUEST_BODY_SNIPPET_MAX_CHARS)
.collect();
if chars.next().is_some() {
snippet.push('…');
}
Some(snippet)
}
/// 错误链上除最外层之外的文本,按由近到远排列。
fn error_source_chain(error: &(dyn std::error::Error + 'static)) -> Vec<String> {
let mut texts = Vec::new();
let mut cursor = error.source();
while let Some(cause) = cursor {
texts.push(cause.to_string());
cursor = cause.source();
}
texts
}
impl TripoError {
pub fn is_retryable(&self) -> bool {
match self {
@@ -178,8 +303,14 @@ impl From<tripo3d_sdk::Error> for TripoError {
status,
},
tripo3d_sdk::Error::Request {
message, status, ..
} => Self::Request { message, status },
message,
status,
body,
source,
} => Self::Request {
message: request_failure_message(message, body.as_deref(), source.as_ref()),
status,
},
tripo3d_sdk::Error::Task { task } => Self::TaskFailure {
task_id: task.task_id.clone(),
status: task.status.to_string(),