Files
Genarrative/server-rs/crates/platform-tripo/src/common/error.rs
T
k88936 ffee739774 platform-tripo 构造配置时拒绝零超时与空白凭据
- TripoSettings::new 改为返回 Result:request_timeout 必须大于零,api_key / base_url 不能是空白
- 新增 TripoSettings::validate 与 TripoError::Configuration,字段是公开的,因此 TripoProviderClient::new 在使用前再校验一次
- 拒绝零超时的理由是同一个值存在两种语义:tokio::time::timeout 立即超时,reqwest 却当成不超时
- api-server 构造入口把构造失败按既有 503 口径映射,并补全 TripoError 的匹配分支
- smoke 示例与 platform-tripo 测试跟进新的构造签名,新增零超时 / 空白凭据用例
2026-09-23 16:16:27 +08:00

343 lines
10 KiB
Rust

use std::fmt;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TripoField {
Prompt,
Model,
NegativePrompt,
Input,
Inputs,
ImageSeed,
ModelSeed,
TextureSeed,
Texture,
Pbr,
TextureQuality,
TextureVersion,
GeometryQuality,
FaceLimit,
AutoSize,
Quad,
SmartLowPoly,
GenerateParts,
Compress,
ExportUv,
ExportOrientation,
Delight,
TaskId,
}
impl fmt::Display for TripoField {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = match self {
Self::Prompt => "prompt",
Self::Model => "model",
Self::NegativePrompt => "negative_prompt",
Self::Input => "input",
Self::Inputs => "inputs",
Self::ImageSeed => "image_seed",
Self::ModelSeed => "model_seed",
Self::TextureSeed => "texture_seed",
Self::Texture => "texture",
Self::Pbr => "pbr",
Self::TextureQuality => "texture_quality",
Self::TextureVersion => "texture_version",
Self::GeometryQuality => "geometry_quality",
Self::FaceLimit => "face_limit",
Self::AutoSize => "auto_size",
Self::Quad => "quad",
Self::SmartLowPoly => "smart_low_poly",
Self::GenerateParts => "generate_parts",
Self::Compress => "compress",
Self::ExportUv => "export_uv",
Self::ExportOrientation => "export_orientation",
Self::Delight => "delight",
Self::TaskId => "task_id",
};
f.write_str(name)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TripoValidationReason {
Required,
InvalidCombination,
/// 取值超出该字段允许的长度或范围,与「参数组合互斥」区分开。
OutOfRange,
}
impl fmt::Display for TripoValidationReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Required => "required",
Self::InvalidCombination => "invalid combination",
Self::OutOfRange => "out of range",
})
}
}
#[derive(Debug)]
pub enum TripoError {
InvalidParameters {
field: Option<TripoField>,
reason: TripoValidationReason,
message: String,
},
SdkInvalidArgument(String),
Api {
code: i64,
message: Option<String>,
suggestion: Option<String>,
status: Option<u16>,
},
Request {
message: String,
status: Option<u16>,
},
TaskFailure {
task_id: String,
status: String,
message: Option<String>,
},
OutputSchema {
task_id: String,
message: String,
},
/// 客户端配置问题:字段由调用方提供,与请求参数、provider 响应都无关。
Configuration {
field: &'static str,
message: String,
},
Sdk {
message: String,
},
}
impl fmt::Display for TripoError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidParameters {
field,
reason,
message,
} => {
write!(f, "invalid Tripo parameters ({reason})")?;
match field {
Some(field) => write!(f, " [{field}]: {message}"),
None => write!(f, ": {message}"),
}
}
Self::SdkInvalidArgument(message) => write!(f, "Tripo SDK invalid argument: {message}"),
Self::Api {
code,
message,
suggestion,
status,
} => write!(
f,
"Tripo API error code={code} status={status:?} message={message:?} suggestion={suggestion:?}"
),
Self::Request { message, status } => {
write!(f, "Tripo request error status={status:?}: {message}")
}
Self::TaskFailure {
task_id,
status,
message,
} => write!(f, "Tripo task {task_id} ended with {status}: {message:?}"),
Self::OutputSchema { task_id, message } => {
write!(f, "Tripo task {task_id} output schema error: {message}")
}
Self::Configuration { field, message } => {
write!(f, "Tripo configuration error [{field}]: {message}")
}
Self::Sdk { message } => write!(f, "Tripo SDK error: {message}"),
}
}
}
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 {
Self::Api { status, .. } => matches!(status, Some(408 | 425 | 429 | 500..=599)),
Self::Request { status, .. } => {
status.is_none() || matches!(status, Some(408 | 425 | 429 | 500..=599))
}
_ => false,
}
}
}
impl From<tripo3d_sdk::Error> for TripoError {
fn from(error: tripo3d_sdk::Error) -> Self {
match error {
tripo3d_sdk::Error::InvalidArgument(message) => Self::SdkInvalidArgument(message),
tripo3d_sdk::Error::Api {
code,
message,
suggestion,
status,
} => Self::Api {
code,
message,
suggestion,
status,
},
tripo3d_sdk::Error::Request {
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(),
message: task.error_message.clone(),
},
tripo3d_sdk::Error::Timeout {
task_id,
timeout_ms,
} => Self::Request {
message: format!("unexpected SDK timeout after {timeout_ms}ms for task {task_id}"),
status: None,
},
tripo3d_sdk::Error::Io(error) => Self::Sdk {
message: error.to_string(),
},
tripo3d_sdk::Error::Serde(error) => Self::Sdk {
message: error.to_string(),
},
}
}
}