diff --git a/server-rs/crates/platform-tripo/src/common/error.rs b/server-rs/crates/platform-tripo/src/common/error.rs index 6697c95c9..fe949a5e6 100644 --- a/server-rs/crates/platform-tripo/src/common/error.rs +++ b/server-rs/crates/platform-tripo/src/common/error.rs @@ -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>, + } + + 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("failed to fetch https://cdn.example.com/a.glb?sig=secret"), + 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 { + 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 { + 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 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(),