修复本地抠图处理失败被审计成可重试 5xx
LocalProcessing 映射为 422 并标记 localProcessing,不再包装为 502。 审计层区分 transport 与 local:无上游状态且非传输故障记 statusClass=local 且 retryable=false。
This commit is contained in:
@@ -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=false、transport=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 400,Code=InvalidImage):bad image".to_string(),
|
||||
None,
|
||||
|
||||
Reference in New Issue
Block a user