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 测试跟进新的构造签名,新增零超时 / 空白凭据用例
This commit is contained in:
2026-09-23 16:16:27 +08:00
parent 825dde6602
commit ffee739774
6 changed files with 121 additions and 6 deletions
@@ -102,6 +102,16 @@ pub(crate) fn map_provider_error(error: TripoError) -> AppError {
"3D 生成任务的结果格式不符合预期,未落库。".to_string(),
)
}
// 配置问题正常在 `provider.rs` 就按 503 拦下;这里只是把枚举匹配补全,
// 真走到这一步说明有调用方绕过了构造入口,按服务不可用上报。
TripoError::Configuration { .. } => {
tracing::error!(error = %error, "tripo client misconfigured");
(
StatusCode::SERVICE_UNAVAILABLE,
"tripo-invalid-configuration",
"3D 生成服务配置不可用。".to_string(),
)
}
TripoError::Request { .. } | TripoError::Sdk { .. } | TripoError::Api { .. } => {
tracing::error!(error = %error, "tripo upstream error");
(
@@ -28,14 +28,17 @@ pub(crate) fn tripo_settings(config: &AppConfig) -> Result<TripoSettings, AppErr
.filter(|value| !value.is_empty())
.ok_or_else(|| not_configured("TRIPO_API_KEY 未配置,无法调用 Tripo 3D 生成服务。"))?;
Ok(TripoSettings::new(
// 上面的取值已经把空地址 / 空密钥挡掉、把超时钳到 >= 1ms,构造器在这里不会再失败;
// 仍然按 503 映射,保持「配置问题在扣费与入队之前失败」的既有口径。
TripoSettings::new(
api_key.to_string(),
base_url.to_string(),
Duration::from_millis(config.tripo_request_timeout_ms.max(1)),
// 重试次数直接决定产物下载的挂起时长,配置异常时封顶到 10 次。
config.tripo_retries.min(10),
TRIPO_USER_AGENT.to_string(),
))
)
.map_err(map_provider_client_error)
}
pub(crate) fn tripo_provider_client(config: &AppConfig) -> Result<TripoProviderClient, AppError> {
@@ -43,7 +43,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Duration::from_secs(60),
2,
"genarrative-tripo-smoke/1".to_string(),
))?;
)?)?;
let text = client
.submit_text_to_model(&Model3dTextToModelParams {
@@ -19,6 +19,9 @@ pub struct TripoProviderClient {
impl TripoProviderClient {
pub fn new(settings: TripoSettings) -> Result<Self, TripoError> {
// 字段是公开的,结构体字面量能绕过 `TripoSettings::new`;真正发起请求的这一端
// 再校验一次,零超时 / 空白凭据就不会拖到第一次产物下载才以误导性错误暴露。
settings.validate()?;
let artifact_client = reqwest::Client::builder()
.user_agent(settings.user_agent.clone())
// 产物是几十 MB 的流,设总超时会把「还在正常下载」判成失败,也会让重试从头重传;
@@ -222,7 +225,8 @@ mod tests {
STALL_TIMEOUT,
0,
"genarrative-test-tripo/1".to_string(),
);
)
.expect("测试配置必须合法");
TripoProviderClient::new(settings).expect("测试配置必须能建出 client")
}
@@ -2,6 +2,8 @@ use std::time::Duration;
use tripo3d_sdk::ClientOptions;
use super::TripoError;
#[derive(Clone)]
pub struct TripoSettings {
pub api_key: String,
@@ -24,20 +26,54 @@ impl std::fmt::Debug for TripoSettings {
}
impl TripoSettings {
/// 构造配置;零超时或空白 `api_key` / `base_url` 属于调用方配置错误,直接在这里失败。
///
/// 零超时必须被拒绝,因为同一个值在两条链路里含义相反:`tokio::time::timeout` 认为
/// 它「已经超时」,`reqwest` 却把它解释成「不超时」;放它进来只会得到「产物下载
/// stalled」这种指着错误方向的报错,而不是一眼看出是配置写错了。
pub fn new(
api_key: String,
base_url: String,
request_timeout: Duration,
retries: u32,
user_agent: String,
) -> Self {
Self {
) -> Result<Self, TripoError> {
let settings = Self {
api_key,
base_url,
request_timeout,
retries,
user_agent,
};
settings.validate()?;
Ok(settings)
}
/// 校验配置自洽。
///
/// 字段都是公开的,调用方可以用结构体字面量绕过 [`TripoSettings::new`]
/// [`crate::TripoProviderClient::new`] 会再校验一次,保证真正发起请求的那一端
/// 拿到的永远是合法配置。
pub fn validate(&self) -> Result<(), TripoError> {
if self.api_key.trim().is_empty() {
return Err(TripoError::Configuration {
field: "api_key",
message: "api_key must not be blank".to_string(),
});
}
if self.base_url.trim().is_empty() {
return Err(TripoError::Configuration {
field: "base_url",
message: "base_url must not be blank".to_string(),
});
}
if self.request_timeout.is_zero() {
return Err(TripoError::Configuration {
field: "request_timeout",
message: "request_timeout must be greater than zero".to_string(),
});
}
Ok(())
}
pub(crate) fn client_options(&self) -> ClientOptions {
@@ -50,3 +86,57 @@ impl TripoSettings {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn settings(request_timeout: Duration, api_key: &str, base_url: &str) -> TripoSettings {
TripoSettings {
api_key: api_key.to_string(),
base_url: base_url.to_string(),
request_timeout,
retries: 2,
user_agent: "genarrative-test-tripo/1".to_string(),
}
}
#[test]
fn zero_timeout_and_blank_credentials_are_rejected() {
for (label, candidate) in [
(
"零超时",
settings(Duration::ZERO, "key", "https://example.com"),
),
(
"空密钥",
settings(Duration::from_secs(1), "", "https://example.com"),
),
(
"空白密钥",
settings(Duration::from_secs(1), " ", "https://example.com"),
),
("空地址", settings(Duration::from_secs(1), "key", "")),
("空白地址", settings(Duration::from_secs(1), "key", " \t")),
] {
let error = candidate
.validate()
.expect_err(&format!("{label} 必须被拒绝"));
assert!(matches!(error, TripoError::Configuration { .. }), "{error}");
assert!(!error.is_retryable(), "{label} 不是可重试错误");
}
}
#[test]
fn new_returns_ready_to_use_settings() {
let settings = TripoSettings::new(
"key".to_string(),
"https://example.com".to_string(),
Duration::from_secs(5),
1,
"genarrative-test-tripo/1".to_string(),
)
.expect("合法配置必须构造成功");
assert_eq!(settings.request_timeout, Duration::from_secs(5));
}
}
@@ -103,6 +103,11 @@ pub enum TripoError {
task_id: String,
message: String,
},
/// 客户端配置问题:字段由调用方提供,与请求参数、provider 响应都无关。
Configuration {
field: &'static str,
message: String,
},
Sdk {
message: String,
},
@@ -143,6 +148,9 @@ impl fmt::Display for TripoError {
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}"),
}
}