From aa6e3b8d798f77973f3c3c49dd6a7e81cc44051c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 16:55:16 +0800 Subject: [PATCH] =?UTF-8?q?SDK=20=E8=AF=B7=E6=B1=82=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E4=B8=A2=E5=BC=83=E5=93=8D=E5=BA=94=E4=BD=93?= =?UTF-8?q?=E4=B8=8E=E5=BA=95=E5=B1=82=E9=94=99=E8=AF=AF=E9=93=BE=20-=20?= =?UTF-8?q?=E6=98=A0=E5=B0=84=20tripo3d=5Fsdk::Error::Request=20=E6=97=B6?= =?UTF-8?q?=E5=B8=A6=E4=B8=8A=20body=20=E4=B8=8E=20source=20=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=20-=20=E5=93=8D=E5=BA=94=E4=BD=93=E6=88=AA=E6=96=AD?= =?UTF-8?q?=E5=88=B0=20512=20=E5=AD=97=E7=AC=A6=EF=BC=8C=E7=96=91=E4=BC=BC?= =?UTF-8?q?=E5=B8=A6=20http(s)=20=E5=9C=B0=E5=9D=80=E7=9A=84=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E4=BD=93=E6=95=B4=E4=BD=93=E4=B8=A2=E5=BC=83=20-=20?= =?UTF-8?q?=E5=8F=AA=E5=8F=96=E9=94=99=E8=AF=AF=E9=93=BE=E5=BA=95=E5=B1=82?= =?UTF-8?q?=E6=96=87=E6=9C=AC=EF=BC=8C=E9=81=BF=E5=85=8D=20reqwest=20?= =?UTF-8?q?=E9=A1=B6=E5=B1=82=20Display=20=E5=B8=A6=E5=87=BA=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E7=AD=BE=E5=90=8D=20URL=20-=20=E8=A1=A5=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E9=93=BE=E6=94=B6=E9=9B=86=E3=80=81=E5=93=8D=E5=BA=94?= =?UTF-8?q?=E4=BD=93=E8=BF=87=E6=BB=A4=E4=B8=8E=E6=88=AA=E6=96=AD=E7=9A=84?= =?UTF-8?q?=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../crates/platform-tripo/src/common/error.rs | 135 +++++++++++++++++- 1 file changed, 133 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 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(),