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:
@@ -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}"),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user