修复本地抠图处理失败被审计成可重试 5xx

LocalProcessing 映射为 422 并标记 localProcessing,不再包装为 502。

审计层区分 transport 与 local:无上游状态且非传输故障记 statusClass=local 且 retryable=false。
This commit is contained in:
2026-07-18 12:00:43 +00:00
parent da7fd1d691
commit 7bce98439e
3 changed files with 120 additions and 20 deletions
@@ -4,7 +4,7 @@
use axum::http::StatusCode;
use platform_matting::MattingError;
use serde_json::json;
use serde_json::{Value, json};
use crate::{
http_error::AppError, openai_image_generation::DownloadedOpenAiImage, state::AppState,
@@ -49,7 +49,7 @@ pub(crate) async fn segment_image_url_with_aliyun_matting(
/// 结构化捕获,这里只做协议中立的读取,不再从中文 message 反推——外部供应商协议归属留在
/// platform-* 层。`InvalidConfig` / `InvalidRequest` / `Sign` 是尚未开始外部调用的本地预检失败;
/// URL 链路在 OSS GET 成功后发生的解码、尺寸或其它本地处理失败由 `LocalProcessing` 表示,
/// 仍然需要进入外部失败审计。
/// 仍然需要进入外部失败审计,但不得包装成可重试的上游 5xx
fn aliyun_matting_failure_to_app_error(error: &MattingError, latency_ms: u64) -> AppError {
let message = error.message();
if !error.external_call_attempted() {
@@ -59,12 +59,28 @@ fn aliyun_matting_failure_to_app_error(error: &MattingError, latency_ms: u64) ->
"message": message,
"timeout": false,
"transport": false,
"localProcessing": false,
"externalCallAttempted": false,
"failureStage": error.failure_stage(),
"latencyMs": latency_ms,
"rawExcerpt": message.chars().take(500).collect::<String>(),
}));
}
// 外部调用已开始,但失败在本地解码 / 校验 / 归一等阶段:要审计,不能标成上游 5xx / 可重试。
if matches!(error, MattingError::LocalProcessing(_)) {
return AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "aliyun-matting",
"message": message,
"timeout": false,
"transport": false,
"localProcessing": true,
"upstreamStatus": Value::Null,
"externalCallAttempted": true,
"failureStage": error.failure_stage(),
"latencyMs": latency_ms,
"rawExcerpt": message.chars().take(500).collect::<String>(),
}));
}
let timeout = error.is_timeout();
let status = if timeout {
StatusCode::GATEWAY_TIMEOUT
@@ -76,6 +92,7 @@ fn aliyun_matting_failure_to_app_error(error: &MattingError, latency_ms: u64) ->
"message": message,
"timeout": timeout,
"transport": error.is_transport(),
"localProcessing": false,
"upstreamStatus": error.upstream_status(),
"externalCallAttempted": true,
"failureStage": error.failure_stage(),
@@ -165,7 +182,8 @@ mod tests {
let mapped = aliyun_matting_failure_to_app_error(&error, 3);
assert!(crate::external_api_audit::matting_failure_external_call_attempted(&mapped));
assert_eq!(mapped.status_code(), StatusCode::BAD_GATEWAY);
// 本地处理失败要审计,但 HTTP 包装不得落成可重试 5xx。
assert_eq!(mapped.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
let details = mapped.details().expect("details present");
assert_eq!(
details
@@ -173,6 +191,19 @@ mod tests {
.and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
details.get("localProcessing").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
details.get("transport").and_then(|v| v.as_bool()),
Some(false)
);
assert!(
details
.get("upstreamStatus")
.is_none_or(|value| value.is_null())
);
assert_eq!(
details.get("failureStage").and_then(|v| v.as_str()),
Some(expected_stage)
@@ -184,6 +215,26 @@ mod tests {
),
expected_stage
);
assert_eq!(
crate::external_api_audit::matting_failure_audit_status_code(&mapped),
None,
"本地处理失败没有上游 HTTP 状态,不能回退包装码"
);
let draft = crate::external_api_audit::build_matting_external_api_failure_draft(
"aliyun-matting",
"imageseg.example".to_string(),
"editor-screen-background-removal",
expected_stage,
crate::external_api_audit::matting_failure_audit_status_code(&mapped),
crate::external_api_audit::matting_failure_audit_timeout(&mapped),
crate::external_api_audit::matting_failure_audit_is_transport(&mapped),
crate::external_api_audit::matting_failure_audit_latency_ms(&mapped),
mapped.message().to_string(),
crate::external_api_audit::matting_failure_audit_raw_excerpt(&mapped),
&crate::external_api_audit::ExternalApiAuditContext::default(),
);
assert_eq!(draft.status_class, Some("local"));
assert!(!draft.retryable);
}
}
@@ -3467,6 +3467,7 @@ pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter_with
"bgfilter_segment",
crate::external_api_audit::matting_failure_audit_status_code(&error),
crate::external_api_audit::matting_failure_audit_timeout(&error),
crate::external_api_audit::matting_failure_audit_is_transport(&error),
crate::external_api_audit::matting_failure_audit_latency_ms(&error),
error.message().to_string(),
crate::external_api_audit::matting_failure_audit_raw_excerpt(&error),
@@ -3552,6 +3553,7 @@ async fn fallback_editor_screen_background_removal(
),
crate::external_api_audit::matting_failure_audit_status_code(&error),
crate::external_api_audit::matting_failure_audit_timeout(&error),
crate::external_api_audit::matting_failure_audit_is_transport(&error),
crate::external_api_audit::matting_failure_audit_latency_ms(&error),
error.message().to_string(),
crate::external_api_audit::matting_failure_audit_raw_excerpt(&error),
@@ -159,6 +159,7 @@ pub(crate) async fn record_matting_external_api_failure(
failure_stage: &'static str,
status_code: Option<u16>,
timeout: bool,
transport: bool,
latency_ms: Option<u64>,
error_message: String,
raw_excerpt: Option<String>,
@@ -170,6 +171,7 @@ pub(crate) async fn record_matting_external_api_failure(
failure_stage,
status_code,
timeout,
transport,
latency_ms,
error_message,
raw_excerpt,
@@ -178,31 +180,41 @@ pub(crate) async fn record_matting_external_api_failure(
record_external_api_failure(state, draft).await;
}
/// 构建抠图失败审计 draft。`transport` 必须由调用方从错误结构化字段读取,
/// 不能用 `status_code.is_none()` 反推——本地处理失败同样没有上游 HTTP 状态。
#[allow(clippy::too_many_arguments)]
fn build_matting_external_api_failure_draft(
pub(crate) fn build_matting_external_api_failure_draft(
provider: &'static str,
endpoint: String,
operation: &'static str,
failure_stage: &'static str,
status_code: Option<u16>,
timeout: bool,
transport: bool,
latency_ms: Option<u64>,
error_message: String,
raw_excerpt: Option<String>,
context: &ExternalApiAuditContext,
) -> ExternalApiFailureDraft {
// status_code=None ⟺ statusClass=transport(见 status_class):DNS / 连接重置 / 读体中断 / 超时
// 这类传输层失败没有上游 HTTP 状态。它们必须与 "transport failures actionable" 语义一致,
// 记为 retryable=true,否则 statusClass=transport 却 retryable=false 会误导告警 / 重试分析。
let has_transport_error = status_code.is_none();
// 传输层:无上游 HTTP 状态 + timeout/transport 标记 → statusClass=transport、retryable=true。
// 本地处理:无上游 HTTP 状态且非 transport → statusClass=local、retryable=false。
// 不得把「status_code=None」一律当成 transport,否则 LocalProcessing 会污染可重试 5xx 分析。
let is_transport_failure = timeout || transport;
let resolved_status_class = if is_transport_failure && status_code.is_none() {
"transport"
} else if status_code.is_none() {
"local"
} else {
status_class(status_code)
};
ExternalApiFailureDraft::new(provider, endpoint, operation, failure_stage, error_message)
.with_status_code(status_code)
.with_optional_status_class(Some(status_class(status_code)))
.with_optional_status_class(Some(resolved_status_class))
.with_timeout(timeout)
.with_retryable(is_retryable_external_api_failure(
status_code,
timeout,
has_transport_error,
is_transport_failure,
))
.with_latency_ms(latency_ms)
.with_raw_excerpt(raw_excerpt)
@@ -210,21 +222,31 @@ fn build_matting_external_api_failure_draft(
}
pub(crate) fn matting_failure_audit_status_code(error: &AppError) -> Option<u16> {
error
// 真实上游 HTTP 状态优先;本地处理 / 传输层都没有可写的上游 status。
if let Some(status) = error
.details()
.and_then(|details| details.get("upstreamStatus"))
.and_then(Value::as_u64)
.and_then(|value| u16::try_from(value).ok())
.or_else(|| {
if matting_failure_audit_is_transport(error) {
None
} else {
Some(error.status_code().as_u16())
}
})
{
return Some(status);
}
if matting_failure_audit_is_local_processing(error) || matting_failure_audit_is_transport(error)
{
return None;
}
Some(error.status_code().as_u16())
}
fn matting_failure_audit_is_transport(error: &AppError) -> bool {
pub(crate) fn matting_failure_audit_is_local_processing(error: &AppError) -> bool {
error
.details()
.and_then(|details| details.get("localProcessing"))
.and_then(Value::as_bool)
.unwrap_or(false)
}
pub(crate) fn matting_failure_audit_is_transport(error: &AppError) -> bool {
matting_failure_audit_timeout(error)
|| error
.details()
@@ -599,7 +621,7 @@ mod tests {
#[test]
fn matting_failure_draft_marks_non_timeout_transport_retryable() {
// status_code=None、timeout=false 的非超时 transport 失败(DNS / 连接重置 / 读体中断)
// status_code=None、timeout=falsetransport=true 的非超时传输故障
// statusClass 必须是 transport 且 retryable=true,否则与 "transport failures actionable" 冲突。
let draft = build_matting_external_api_failure_draft(
"bgfilter",
@@ -608,6 +630,7 @@ mod tests {
"bgfilter_segment",
None,
false,
true,
Some(67),
"请求 BgFilter 服务失败:dns error".to_string(),
Some("dns error".to_string()),
@@ -623,6 +646,29 @@ mod tests {
);
}
#[test]
fn matting_failure_draft_marks_local_processing_non_retryable() {
// 本地处理失败没有上游 HTTP 状态,也不是 transport;不得记成可重试 5xx/transport。
let draft = build_matting_external_api_failure_draft(
"aliyun-matting",
"imageseg.example".to_string(),
"editor-screen-background-removal",
"source_decode",
None,
false,
false,
Some(3),
"解析待抠图图片失败:invalid png".to_string(),
Some("invalid png".to_string()),
&ExternalApiAuditContext::default(),
);
assert_eq!(draft.status_code, None);
assert_eq!(draft.status_class, Some("local"));
assert!(!draft.timeout);
assert!(!draft.retryable);
}
#[test]
fn matting_failure_draft_keeps_client_error_non_retryable() {
// 真实上游 4xx(非 429 / 408)不是传输故障,仍应 retryable=false,不能被误判为可重试。
@@ -633,6 +679,7 @@ mod tests {
"aliyun_segment",
Some(400),
false,
false,
Some(12),
"通用抠图接口返回失败(HTTP 400Code=InvalidImage):bad image".to_string(),
None,