diff --git a/server-rs/crates/api-server/src/aliyun_matting.rs b/server-rs/crates/api-server/src/aliyun_matting.rs index 3746f2f1c..16487ece7 100644 --- a/server-rs/crates/api-server/src/aliyun_matting.rs +++ b/server-rs/crates/api-server/src/aliyun_matting.rs @@ -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::(), })) })?; 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 { + let marker = "HTTP "; + let start = message.find(marker)? + marker.len(); + let digits = message[start..] + .chars() + .take_while(|value| value.is_ascii_digit()) + .collect::(); + 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 429,Code=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 400,Code=InvalidImage):bad image" + )); + } +} diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index 1cf3bdb5d..072273b23 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -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)? diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 029e9ff85..6efba3544 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -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) diff --git a/server-rs/crates/api-server/src/external_api_audit.rs b/server-rs/crates/api-server/src/external_api_audit.rs index cc0dc824b..3d129e021 100644 --- a/server-rs/crates/api-server/src/external_api_audit.rs +++ b/server-rs/crates/api-server/src/external_api_audit.rs @@ -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, + timeout: bool, error_message: String, raw_excerpt: Option, ) { - 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 { + 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 { + 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); + } }