修复抠图外部失败审计分类

保留 BgFilter 上游真实 HTTP 状态与超时标记

保留阿里云抠图上游状态、超时与错误摘要

补充审计分类单测覆盖 429 与 timeout 场景
This commit is contained in:
2026-07-09 07:26:10 +00:00
parent 14a5cde9dd
commit 1847fde8b7
4 changed files with 193 additions and 26 deletions
@@ -30,9 +30,19 @@ pub(crate) async fn segment_image_with_aliyun_matting(
.segment_image_to_transparent_png(image.bytes.as_slice(), &file_name)
.await
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
let message = error.message();
let timeout = aliyun_matting_error_is_timeout(message);
let status = if timeout {
StatusCode::GATEWAY_TIMEOUT
} else {
StatusCode::BAD_GATEWAY
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-matting",
"message": error.message(),
"message": message,
"timeout": timeout,
"upstreamStatus": aliyun_matting_error_http_status(message),
"rawExcerpt": message.chars().take(500).collect::<String>(),
}))
})?;
tracing::info!(
@@ -48,3 +58,51 @@ pub(crate) async fn segment_image_with_aliyun_matting(
extension: "png".to_string(),
})
}
fn aliyun_matting_error_is_timeout(message: &str) -> bool {
let normalized = message.to_ascii_lowercase();
normalized.contains("timeout") || normalized.contains("timed out")
}
fn aliyun_matting_error_http_status(message: &str) -> Option<u16> {
let marker = "HTTP ";
let start = message.find(marker)? + marker.len();
let digits = message[start..]
.chars()
.take_while(|value| value.is_ascii_digit())
.collect::<String>();
digits.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aliyun_matting_error_http_status_extracts_provider_status() {
assert_eq!(
aliyun_matting_error_http_status(
"通用抠图接口返回失败(HTTP 429Code=Throttled):QPS exceeded"
),
Some(429)
);
assert_eq!(
aliyun_matting_error_http_status("下载抠图结果失败(HTTP 403"),
Some(403)
);
assert_eq!(aliyun_matting_error_http_status("网络失败"), None);
}
#[test]
fn aliyun_matting_error_timeout_detects_transport_timeout() {
assert!(aliyun_matting_error_is_timeout(
"通用抠图请求失败:operation timed out"
));
assert!(aliyun_matting_error_is_timeout(
"GetOssStsToken 请求失败:request timeout"
));
assert!(!aliyun_matting_error_is_timeout(
"通用抠图接口返回失败(HTTP 400Code=InvalidImage):bad image"
));
}
}
@@ -2245,9 +2245,11 @@ async fn remove_editor_character_animation_frame_backgrounds(
state.config.aliyun_matting_endpoint.clone(),
"editor-character-animation-frame-matting",
"aliyun_segment",
error.status_code().as_u16(),
crate::external_api_audit::matting_failure_audit_status_code(&error),
crate::external_api_audit::matting_failure_audit_timeout(&error),
error.message().to_string(),
Some(format!("frame_index={frame_index}")),
crate::external_api_audit::matting_failure_audit_raw_excerpt(&error)
.or_else(|| Some(format!("frame_index={frame_index}"))),
)
.await;
remove_editor_generated_green_screen_background(&image, screen_color)?
@@ -2266,9 +2266,10 @@ async fn remove_editor_generated_screen_background_with_bgfilter(
state.config.editor_bgfilter_base_url.clone(),
"editor-screen-background-removal",
"bgfilter_segment",
error.status_code().as_u16(),
crate::external_api_audit::matting_failure_audit_status_code(&error),
crate::external_api_audit::matting_failure_audit_timeout(&error),
error.message().to_string(),
None,
crate::external_api_audit::matting_failure_audit_raw_excerpt(&error),
)
.await;
match crate::aliyun_matting::segment_image_with_aliyun_matting(
@@ -2294,9 +2295,10 @@ async fn remove_editor_generated_screen_background_with_bgfilter(
state.config.aliyun_matting_endpoint.clone(),
"editor-screen-background-removal",
"aliyun_segment",
error.status_code().as_u16(),
crate::external_api_audit::matting_failure_audit_status_code(&error),
crate::external_api_audit::matting_failure_audit_timeout(&error),
error.message().to_string(),
None,
crate::external_api_audit::matting_failure_audit_raw_excerpt(&error),
)
.await;
remove_editor_generated_green_screen_background(image, screen_color)
@@ -6,7 +6,7 @@ use serde_json::{Value, json};
use time::OffsetDateTime;
use uuid::Uuid;
use crate::{state::AppState, tracking::TrackingEventDraft};
use crate::{http_error::AppError, state::AppState, tracking::TrackingEventDraft};
pub(crate) const EXTERNAL_API_FAILURE_EVENT_KEY: &str = "external_api_call_failure";
pub(crate) const EXTERNAL_API_AUDIT_MODULE_KEY: &str = "external-api";
@@ -156,29 +156,63 @@ pub(crate) async fn record_matting_external_api_failure(
endpoint: String,
operation: &'static str,
failure_stage: &'static str,
status_code: u16,
status_code: Option<u16>,
timeout: bool,
error_message: String,
raw_excerpt: Option<String>,
) {
let draft = ExternalApiFailureDraft::new(
provider,
endpoint,
operation,
failure_stage,
error_message,
)
.with_status_code(Some(status_code))
.with_optional_status_class(Some(status_class(Some(status_code))))
.with_retryable(is_retryable_external_api_failure(
Some(status_code),
false,
false,
))
.with_raw_excerpt(raw_excerpt)
.with_audit_context(context);
let draft =
ExternalApiFailureDraft::new(provider, endpoint, operation, failure_stage, error_message)
.with_status_code(status_code)
.with_optional_status_class(Some(status_class(status_code)))
.with_timeout(timeout)
.with_retryable(is_retryable_external_api_failure(
status_code,
timeout,
false,
))
.with_raw_excerpt(raw_excerpt)
.with_audit_context(context);
record_external_api_failure(state, draft).await;
}
pub(crate) fn matting_failure_audit_status_code(error: &AppError) -> Option<u16> {
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_timeout(error) {
None
} else {
Some(error.status_code().as_u16())
}
})
}
pub(crate) fn matting_failure_audit_timeout(error: &AppError) -> bool {
error
.details()
.and_then(|details| details.get("timeout"))
.and_then(Value::as_bool)
.unwrap_or(false)
}
pub(crate) fn matting_failure_audit_raw_excerpt(error: &AppError) -> Option<String> {
error
.details()
.and_then(|details| {
details
.get("upstreamMessage")
.or_else(|| details.get("rawExcerpt"))
})
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.chars().take(800).collect())
}
pub(crate) fn build_external_api_failure_draft_from_platform_image_audit(
audit: &PlatformImageFailureAudit,
) -> ExternalApiFailureDraft {
@@ -499,4 +533,75 @@ mod tests {
assert_eq!(draft.metadata["statusCode"], 200);
assert_eq!(draft.metadata["statusClass"], "5xx");
}
#[test]
fn matting_failure_audit_uses_upstream_status_instead_of_wrapped_status() {
let error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "bgfilter",
"message": "BgFilter 服务返回非成功状态",
"upstreamStatus": 429,
"upstreamMessage": "too many requests",
}));
let status_code = matting_failure_audit_status_code(&error);
let timeout = matting_failure_audit_timeout(&error);
let tracking = build_external_api_failure_tracking_draft(
&ExternalApiFailureDraft::new(
"bgfilter",
"https://bgfilter.example/remove-background",
"editor-screen-background-removal",
"bgfilter_segment",
error.message(),
)
.with_status_code(status_code)
.with_optional_status_class(Some(status_class(status_code)))
.with_timeout(timeout)
.with_retryable(is_retryable_external_api_failure(
status_code,
timeout,
false,
))
.with_raw_excerpt(matting_failure_audit_raw_excerpt(&error)),
);
assert_eq!(tracking.metadata["statusCode"], 429);
assert_eq!(tracking.metadata["statusClass"], "4xx");
assert_eq!(tracking.metadata["timeout"], false);
assert_eq!(tracking.metadata["retryable"], true);
assert_eq!(tracking.metadata["rawExcerpt"], "too many requests");
}
#[test]
fn matting_failure_audit_keeps_transport_timeout_classification() {
let error = AppError::from_status(StatusCode::GATEWAY_TIMEOUT).with_details(json!({
"provider": "bgfilter",
"message": "请求 BgFilter 服务失败:operation timed out",
"timeout": true,
}));
let status_code = matting_failure_audit_status_code(&error);
let timeout = matting_failure_audit_timeout(&error);
let tracking = build_external_api_failure_tracking_draft(
&ExternalApiFailureDraft::new(
"bgfilter",
"https://bgfilter.example/remove-background",
"editor-screen-background-removal",
"bgfilter_segment",
error.message(),
)
.with_status_code(status_code)
.with_optional_status_class(Some(status_class(status_code)))
.with_timeout(timeout)
.with_retryable(is_retryable_external_api_failure(
status_code,
timeout,
false,
)),
);
assert_eq!(tracking.metadata["statusCode"], Value::Null);
assert_eq!(tracking.metadata["statusClass"], "transport");
assert_eq!(tracking.metadata["timeout"], true);
assert_eq!(tracking.metadata["retryable"], true);
}
}