SDK 请求错误不再丢弃响应体与底层错误链
- 映射 tripo3d_sdk::Error::Request 时带上 body 与 source 字段 - 响应体截断到 512 字符,疑似带 http(s) 地址的响应体整体丢弃 - 只取错误链底层文本,避免 reqwest 顶层 Display 带出完整签名 URL - 补错误链收集、响应体过滤与截断的用例
This commit is contained in:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user