ffee739774
- 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 测试跟进新的构造签名,新增零超时 / 空白凭据用例
343 lines
10 KiB
Rust
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(),
|
|
},
|
|
}
|
|
}
|
|
}
|