provider 报 unknown 任务状态时改为可重试,不再直接判死

- platform-tripo:`TaskStatus::Unknown` 不再归一成 `OutputSchema` 终态失败,改成新的 `TripoError::TaskStatusUnknown`,并把它的可重试性判为真(SDK 自己也没把 unknown 算进终态)
- platform-tripo:补用例——provider 回 `"status":"unknown"` 时必须返回可重试的 `TaskStatusUnknown`,不能当成正常快照
- api-server:错误映射补 `TaskStatusUnknown` 分支(502 + 固定文案,轮询侧早已按可重试继续查到截止时间)
- api-server worker:状态查询遇到可重试错误时补一条 warn 日志,便于区分「一直是 unknown」和「一次网络抖动」
- docs:Provider 集成方案写明 unknown 按可重试处理及其理由
This commit is contained in:
2026-09-24 21:06:04 +08:00
parent 2aa59328de
commit e20ff2e89e
6 changed files with 72 additions and 4 deletions
@@ -14,7 +14,7 @@
## 边界与状态
`platform-tripo` 是唯一接触 `tripo3d-sdk` 的边界。它返回自己的 provider DTO,不让 SDK 类型穿透到未来的 `api-server`。Tripo 的 `success` 映射为 `completed`;`failed`/`banned` 映射为 `failed`;`cancelled` 和 `expired` 保留为独立终态;未知状态返回结构化错误。
`platform-tripo` 是唯一接触 `tripo3d-sdk` 的边界。它返回自己的 provider DTO,不让 SDK 类型穿透到未来的 `api-server`。Tripo 的 `success` 映射为 `completed`;`failed`/`banned` 映射为 `failed`;`cancelled` 和 `expired` 保留为独立终态;`unknown` 不在 SDK 的终态集合里,因此按「这次看不懂」处理,返回可重试的 `TripoError::TaskStatusUnknown`,由轮询侧继续查到截止时间 —— 判成终态失败不可恢复,而且 provider 以后新增状态时会把仍在跑的任务整批误杀。
provider task 结果与产品资源结果分离。provider adapter 不生成 `resourceId` 或 `assetId`;未来应用层在资源持久化后再构造带资源 ID 的产品 DTO。模型 URL 被视为临时 provider 引用,下载由显式方法完成。`TripoUrl` 的 `Debug` 与 `redacted()` 只输出 scheme、host 与 path,隐藏可能带签名的 query 与 fragment,并保留命中的分隔符(`?` 或 `#`),不把 fragment 显示成 query;带 userinfo 的地址在解析阶段直接拒绝;主机必须是公网地址,回环、内网、链路本地、组播与保留段(含云元数据地址 `169.254.169.254`)一律拒收,域名层面拦住 `localhost` 及其子域。域名解析出的真实地址在这一层看不见,域名指向内网的情况要靠连接层处理,属已知限制。完整 URL 仅通过 `as_str()` 显式读取;`TripoUrl` 不实现 `Display`,避免 `{}` 这类通用格式化把带签名的完整地址写进日志,冒烟示例同样只打印脱敏后的地址。
@@ -144,6 +144,15 @@ pub(crate) fn map_provider_error(error: TripoError) -> AppError {
"3D 生成任务的结果格式不符合预期,未落库。".to_string(),
)
}
// 轮询侧按可重试继续查到截止时间,真走到这里说明预估的时间已经用尽。
TripoError::TaskStatusUnknown { .. } => {
tracing::error!(error = %error, "tripo task status is unrecognized");
(
StatusCode::BAD_GATEWAY,
"tripo-task-status-unknown",
"3D 生成服务的任务状态暂时无法识别,请稍后重试。".to_string(),
)
}
// 配置问题正常在 `provider.rs` 就按 503 拦下;这里只是把枚举匹配补全,
// 真走到这一步说明有调用方绕过了构造入口,按服务不可用上报。
TripoError::Configuration { .. } => {
@@ -252,6 +252,9 @@ async fn poll_until_terminal(
// 查询本身已经带过一轮退避重试;仍是瞬时故障时继续按轮询间隔查到预算用尽,
// 不因为一次 provider 抖动就作废这笔已经提交、不能重来的任务。
Err(error) if error.is_retryable() => {
// 包括 provider 报的 `unknown` 状态:继续查到截止时间,日志留痕便于区分
// 「一直是 unknown」和「一次网络抖动」。
tracing::warn!(error = %error, "3D 生成任务状态查询遇到可重试错误,继续轮询");
let remaining = provider_deadline.saturating_duration_since(Instant::now());
tokio::time::sleep(MODEL3D_POLL_INTERVAL.min(remaining)).await;
continue;
@@ -522,6 +522,47 @@ mod tests {
assert_eq!(snapshot.status, Model3dTaskStatus::Running);
}
/// provider 报 `unknown` 状态:按可重试返回,不判终态失败(评审 #57)。
#[tokio::test]
async fn task_status_query_treats_unknown_status_as_retryable() {
let addr = spawn_mock_server(|mut socket| async move {
read_request(&mut socket).await;
let body = serde_json::json!({
"code": 0,
"data": {
"task_id": "task-1",
"type": "text_to_model",
"status": "unknown",
"progress": 10
}
})
.to_string();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
Content-Length: {}\r\n\r\n{body}",
body.len()
);
let _ = socket.write_all(response.as_bytes()).await;
let _ = socket.flush().await;
})
.await;
let error = test_client_with_base_url(&format!("http://{addr}"), 0)
.get_task(&TripoTaskHandle {
task_id: "task-1".to_string(),
})
.await
.expect_err("认不出来的状态不能当成正常快照返回");
assert!(
matches!(error, TripoError::TaskStatusUnknown { .. }),
"{error:?}"
);
assert!(
error.is_retryable(),
"unknown 是暂时的看不懂,必须可重试:{error}"
);
}
/// 404 是确定性失败:只打一次请求就返回,不按退避重试。
#[tokio::test]
async fn task_status_query_does_not_retry_deterministic_failures() {
@@ -108,6 +108,14 @@ pub enum TripoError {
task_id: String,
message: String,
},
/// provider 返回了 `unknown` 这一档任务状态。
///
/// SDK 自己也不把 `unknown` 算作终态,因此这里按「暂时看不懂」处理,而不是终态失败:
/// Tripo job 固定只尝试一次、判死不可恢复,而 provider 以后新增状态时,旧代码会把
/// 仍在跑的任务整批判失败。归成可重试后,轮询侧会继续查到截止时间(评审 #57)。
TaskStatusUnknown {
task_id: String,
},
/// 客户端配置问题:字段由调用方提供,与请求参数、provider 响应都无关。
Configuration {
field: &'static str,
@@ -162,6 +170,9 @@ impl fmt::Display for TripoError {
Self::OutputSchema { task_id, message } => {
write!(f, "Tripo task {task_id} output schema error: {message}")
}
Self::TaskStatusUnknown { task_id } => {
write!(f, "Tripo task {task_id} reported the unknown task status")
}
Self::Configuration { field, message } => {
write!(f, "Tripo configuration error [{field}]: {message}")
}
@@ -414,6 +425,9 @@ impl TripoError {
match self {
Self::Api { status, .. } => matches!(status, Some(408 | 425 | 429 | 500..=599)),
Self::Request { retryable, .. } => *retryable,
// `unknown` 是「这次看不懂」,下次查询可能就看得懂;终态失败不可恢复,
// 所以判成可重试(评审 #57)。
Self::TaskStatusUnknown { .. } => true,
_ => false,
}
}
@@ -66,10 +66,11 @@ fn map_status(status: TaskStatus, task_id: &str) -> Result<Model3dTaskStatus, Tr
TaskStatus::Failed | TaskStatus::Banned => Ok(Model3dTaskStatus::Failed),
TaskStatus::Cancelled => Ok(Model3dTaskStatus::Cancelled),
TaskStatus::Expired => Ok(Model3dTaskStatus::Expired),
// 与其它映射失败一致带上 task id:provider 异常要能和具体任务对上。
TaskStatus::Unknown => Err(TripoError::OutputSchema {
// `unknown` 是「这次看不懂」,不是「任务结束了」:SDK 自己也没把它算进终态。
// 按可重试错误返回(评审 #57),由轮询侧按轮询间隔继续查到截止时间——判成终态
// 就等于把一笔已经提交、已经计费的任务永久作废,provider 以后新增状态时更会成批误杀。
TaskStatus::Unknown => Err(TripoError::TaskStatusUnknown {
task_id: task_id.into(),
message: "Tripo returned unknown task status".into(),
}),
}
}