use std::time::Instant; #[cfg(test)] use axum::http::StatusCode; use module_runtime::RuntimeTrackingScopeKind; use platform_image::PlatformImageFailureAudit; use serde_json::{Value, json}; use time::OffsetDateTime; use uuid::Uuid; 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"; #[derive(Clone, Debug)] pub(crate) struct ExternalApiFailureDraft { pub(crate) provider: &'static str, pub(crate) endpoint: String, pub(crate) operation: String, pub(crate) failure_stage: &'static str, pub(crate) status_code: Option, pub(crate) status_class: Option<&'static str>, pub(crate) timeout: bool, pub(crate) retryable: bool, pub(crate) error_message: String, pub(crate) error_source: Option, pub(crate) raw_excerpt: Option, pub(crate) latency_ms: Option, pub(crate) prompt_chars: Option, pub(crate) reference_image_count: Option, pub(crate) image_model: Option<&'static str>, pub(crate) user_id: Option, pub(crate) profile_id: Option, pub(crate) request_id: Option, } impl ExternalApiFailureDraft { pub(crate) fn new( provider: &'static str, endpoint: impl Into, operation: impl Into, failure_stage: &'static str, error_message: impl Into, ) -> Self { Self { provider, endpoint: endpoint.into(), operation: operation.into(), failure_stage, status_code: None, status_class: None, timeout: false, retryable: false, error_message: error_message.into(), error_source: None, raw_excerpt: None, latency_ms: None, prompt_chars: None, reference_image_count: None, image_model: None, user_id: None, profile_id: None, request_id: None, } } pub(crate) fn with_status_code(mut self, status_code: Option) -> Self { self.status_code = status_code; self } pub(crate) fn with_optional_status_class(mut self, status_class: Option<&'static str>) -> Self { self.status_class = status_class; self } pub(crate) fn with_timeout(mut self, timeout: bool) -> Self { self.timeout = timeout; self } pub(crate) fn with_retryable(mut self, retryable: bool) -> Self { self.retryable = retryable; self } pub(crate) fn with_error_source(mut self, error_source: Option) -> Self { self.error_source = error_source; self } pub(crate) fn with_raw_excerpt(mut self, raw_excerpt: Option) -> Self { self.raw_excerpt = raw_excerpt; self } pub(crate) fn with_latency_ms(mut self, latency_ms: Option) -> Self { self.latency_ms = latency_ms; self } pub(crate) fn with_prompt_chars(mut self, prompt_chars: Option) -> Self { self.prompt_chars = prompt_chars; self } pub(crate) fn with_reference_image_count( mut self, reference_image_count: Option, ) -> Self { self.reference_image_count = reference_image_count; self } pub(crate) fn with_image_model(mut self, image_model: Option<&'static str>) -> Self { self.image_model = image_model; self } pub(crate) fn with_user_id(mut self, user_id: Option) -> Self { self.user_id = user_id; self } pub(crate) fn with_profile_id(mut self, profile_id: Option) -> Self { self.profile_id = profile_id; self } pub(crate) fn with_request_id(mut self, request_id: Option) -> Self { self.request_id = request_id; self } pub(crate) fn with_audit_context(mut self, context: &ExternalApiAuditContext) -> Self { self.user_id = context.user_id.clone(); self.profile_id = context.profile_id.clone(); self.request_id = context.request_id.clone(); self } } /// 外部 API 失败审计的调用方上下文(用户 / 档案 / 请求 id)。 #[derive(Clone, Debug, Default)] pub(crate) struct ExternalApiAuditContext { pub(crate) user_id: Option, pub(crate) profile_id: Option, pub(crate) request_id: Option, /// 父流程允许外部调用占用到的绝对时刻。该字段只在进程内用于预算截断, /// 不写入 tracking metadata,也不会通过内部协议传递绝对时间。 pub(crate) external_call_deadline: Option, } /// 抠图供应商(BgFilter / 阿里云通用抠图)调用失败的统一失败审计入口。 /// 即使随后兜底成功,供应商故障也必须进入 OTLP + tracking_event,不能只 warn! 后静默。 #[allow(clippy::too_many_arguments)] pub(crate) async fn record_matting_external_api_failure( state: &AppState, context: &ExternalApiAuditContext, provider: &'static str, endpoint: String, operation: &'static str, failure_stage: &'static str, status_code: Option, timeout: bool, transport: bool, latency_ms: Option, error_message: String, raw_excerpt: Option, ) { let draft = build_matting_external_api_failure_draft( provider, endpoint, operation, failure_stage, status_code, timeout, transport, latency_ms, error_message, raw_excerpt, context, ); record_external_api_failure(state, draft).await; } /// BgFilter worker 专用入口:保留同一份 OTLP / tracking draft,但只允许写入本进程 /// 独立 outbox。outbox 缺失、满载或写盘失败时丢弃,禁止在受限 worker 中逐条同步 /// 直写 SpacetimeDB。 #[allow(clippy::too_many_arguments)] pub(crate) async fn record_matting_external_api_failure_outbox_only( state: &AppState, context: &ExternalApiAuditContext, provider: &'static str, endpoint: String, operation: &'static str, failure_stage: &'static str, status_code: Option, timeout: bool, transport: bool, latency_ms: Option, error_message: String, raw_excerpt: Option, ) { let draft = build_matting_external_api_failure_draft( provider, endpoint, operation, failure_stage, status_code, timeout, transport, latency_ms, error_message, raw_excerpt, context, ); record_external_api_failure_with_policy( state, draft, ExternalApiAuditPersistencePolicy::RequireOutboxDropOnFailure, ) .await; } /// 构建抠图失败审计 draft。`transport` 必须由调用方从错误结构化字段读取, /// 不能用 `status_code.is_none()` 反推——本地处理失败同样没有上游 HTTP 状态。 #[allow(clippy::too_many_arguments)] pub(crate) fn build_matting_external_api_failure_draft( provider: &'static str, endpoint: String, operation: &'static str, failure_stage: &'static str, status_code: Option, timeout: bool, transport: bool, latency_ms: Option, error_message: String, raw_excerpt: Option, context: &ExternalApiAuditContext, ) -> ExternalApiFailureDraft { // 传输层:无上游 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(resolved_status_class)) .with_timeout(timeout) .with_retryable(is_retryable_external_api_failure( status_code, timeout, is_transport_failure, )) .with_latency_ms(latency_ms) .with_raw_excerpt(raw_excerpt) .with_audit_context(context) } pub(crate) fn matting_failure_audit_status_code(error: &AppError) -> Option { // 真实上游 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()) { 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()) } 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() .and_then(|details| details.get("transport")) .and_then(Value::as_bool) .unwrap_or(false) } 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_latency_ms(error: &AppError) -> Option { error .details() .and_then(|details| details.get("latencyMs")) .and_then(Value::as_u64) } pub(crate) fn matting_failure_external_call_attempted(error: &AppError) -> bool { error .details() .and_then(|details| details.get("externalCallAttempted")) .and_then(Value::as_bool) .unwrap_or(true) } pub(crate) fn matting_failure_audit_failure_stage( error: &AppError, fallback: &'static str, ) -> &'static str { let stage = error .details() .and_then(|details| details.get("failureStage")) .and_then(Value::as_str); match stage { Some("preflight") => "preflight", Some("source_download") => "source_download", Some("source_decode") => "source_decode", Some("source_validate") => "source_validate", Some("source_normalize") => "source_normalize", Some("temp_upload") => "temp_upload", Some("aliyun_segment") => "aliyun_segment", Some("result_download") => "result_download", Some("result_decode") => "result_decode", Some("result_validate") => "result_validate", Some("result_encode") => "result_encode", _ => fallback, } } 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 { ExternalApiFailureDraft::new( audit.provider, audit.endpoint.clone(), audit.operation.clone(), audit.failure_stage, audit.error_message.clone(), ) .with_status_code(audit.status_code) .with_optional_status_class(audit.status_class) .with_timeout(audit.timeout) .with_retryable(audit.retryable) .with_error_source(audit.error_source.clone()) .with_raw_excerpt(audit.raw_excerpt.clone()) .with_latency_ms(audit.latency_ms) .with_prompt_chars(audit.prompt_chars) .with_reference_image_count(audit.reference_image_count) .with_image_model(audit.image_model) .with_user_id(None) .with_profile_id(None) .with_request_id(None) } /// 中文注释:下载图片、OSS 读写等非标准 HTTP 状态统一显式归类,避免 OTLP 低基数 label 误落到 `transport`。 #[cfg(test)] pub(crate) fn app_error_status_class(status_code: StatusCode) -> &'static str { status_class(Some(status_code.as_u16())) } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ExternalApiAuditPersistencePolicy { PreferOutboxThenSync, RequireOutboxDropOnFailure, } impl ExternalApiAuditPersistencePolicy { fn allows_sync_fallback(self) -> bool { matches!(self, Self::PreferOutboxThenSync) } } /// 中文注释:外部供应商失败同时进入 OTLP 和 tracking_event;失败审计不能反向阻断主业务错误返回。 pub(crate) async fn record_external_api_failure(state: &AppState, draft: ExternalApiFailureDraft) { record_external_api_failure_with_policy( state, draft, ExternalApiAuditPersistencePolicy::PreferOutboxThenSync, ) .await; } async fn record_external_api_failure_with_policy( state: &AppState, draft: ExternalApiFailureDraft, persistence_policy: ExternalApiAuditPersistencePolicy, ) { record_external_api_failure_otlp(&draft); let tracking_event = build_external_api_failure_tracking_draft(&draft); if let Some(outbox) = state.tracking_outbox() { match outbox .enqueue(crate::tracking::build_tracking_event_input( tracking_event.clone(), )) .await { Ok(crate::tracking_outbox::TrackingOutboxEnqueueOutcome::Enqueued) => {} Ok(crate::tracking_outbox::TrackingOutboxEnqueueOutcome::Dropped { reason }) => { if !persistence_policy.allows_sync_fallback() { crate::telemetry::record_external_api_audit_dropped(draft.provider, reason); tracing::warn!( provider = draft.provider, endpoint = %draft.endpoint, operation = %draft.operation, failure_stage = draft.failure_stage, reason, "外部 API 失败审计写入专用 outbox 被保护阈值拒绝,已丢弃" ); return; } tracing::warn!( provider = draft.provider, endpoint = %draft.endpoint, operation = %draft.operation, failure_stage = draft.failure_stage, reason, "外部 API 失败审计写入 outbox 被保护阈值拒绝,回退同步直写 SpacetimeDB" ); crate::tracking::record_tracking_event_after_success( state, &audit_request_context(), tracking_event, ) .await; } Err(error) => { if !persistence_policy.allows_sync_fallback() { crate::telemetry::record_external_api_audit_dropped( draft.provider, "outbox_error", ); tracing::warn!( provider = draft.provider, endpoint = %draft.endpoint, operation = %draft.operation, failure_stage = draft.failure_stage, error = %error, "外部 API 失败审计写入专用 outbox 失败,已丢弃" ); return; } tracing::warn!( provider = draft.provider, endpoint = %draft.endpoint, operation = %draft.operation, failure_stage = draft.failure_stage, error = %error, "外部 API 失败审计写入 outbox 失败,回退同步直写 SpacetimeDB" ); crate::tracking::record_tracking_event_after_success( state, &audit_request_context(), tracking_event, ) .await; } } return; } if !persistence_policy.allows_sync_fallback() { crate::telemetry::record_external_api_audit_dropped(draft.provider, "outbox_missing"); tracing::warn!( provider = draft.provider, endpoint = %draft.endpoint, operation = %draft.operation, failure_stage = draft.failure_stage, "外部 API 失败审计缺少专用 outbox,已丢弃" ); return; } crate::tracking::record_tracking_event_after_success( state, &audit_request_context(), tracking_event, ) .await; } pub(crate) fn build_external_api_failure_tracking_draft( failure: &ExternalApiFailureDraft, ) -> TrackingEventDraft { let mut draft = TrackingEventDraft::new( EXTERNAL_API_FAILURE_EVENT_KEY, EXTERNAL_API_AUDIT_MODULE_KEY, ); draft.scope_kind = RuntimeTrackingScopeKind::Module; draft.scope_id = failure.provider.to_string(); draft.user_id = failure.user_id.clone(); draft.owner_user_id = failure.user_id.clone(); draft.profile_id = failure.profile_id.clone(); draft.metadata = build_external_api_failure_metadata(failure); draft } fn build_external_api_failure_metadata(failure: &ExternalApiFailureDraft) -> Value { let mut metadata = json!({ "provider": failure.provider, "endpoint": failure.endpoint, "operation": failure.operation, "failureStage": failure.failure_stage, "statusCode": failure.status_code, "statusClass": failure.status_class.unwrap_or_else(|| status_class(failure.status_code)), "timeout": failure.timeout, "retryable": failure.retryable, "errorMessage": truncate_field(failure.error_message.as_str(), 1_000), "occurredAt": current_utc_iso_text(), }); if let Some(latency_ms) = failure.latency_ms { metadata["latencyMs"] = json!(latency_ms); } if let Some(prompt_chars) = failure.prompt_chars { metadata["promptChars"] = json!(prompt_chars); } if let Some(reference_image_count) = failure.reference_image_count { metadata["referenceImageCount"] = json!(reference_image_count); } if let Some(image_model) = failure.image_model { metadata["imageModel"] = json!(image_model); } if let Some(user_id) = failure .user_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { metadata["userId"] = json!(truncate_field(user_id, 1_000)); } if let Some(profile_id) = failure .profile_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { metadata["profileId"] = json!(truncate_field(profile_id, 1_000)); } if let Some(request_id) = failure .request_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { metadata["requestId"] = json!(truncate_field(request_id, 1_000)); } if let Some(source) = failure .error_source .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { metadata["errorSource"] = json!(truncate_field(source, 1_000)); } if let Some(excerpt) = failure .raw_excerpt .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { metadata["rawExcerpt"] = json!(truncate_field(excerpt, 800)); } metadata } pub(crate) fn is_retryable_external_api_failure( status_code: Option, timeout: bool, connect: bool, ) -> bool { // 429 Too Many Requests / 408 Request Timeout / 5xx 视为可重试。 timeout || connect || status_code.is_some_and(|status| status == 429 || status == 408 || status >= 500) } fn record_external_api_failure_otlp(failure: &ExternalApiFailureDraft) { crate::telemetry::record_external_api_failure( failure.provider, failure.failure_stage, failure .status_class .unwrap_or_else(|| status_class(failure.status_code)), failure.retryable, ); tracing::error!( provider = failure.provider, endpoint = %failure.endpoint, operation = %failure.operation, failure_stage = failure.failure_stage, status_code = failure.status_code, status_class = failure.status_class.unwrap_or_else(|| status_class(failure.status_code)), timeout = failure.timeout, retryable = failure.retryable, latency_ms = failure.latency_ms, prompt_chars = failure.prompt_chars, reference_image_count = failure.reference_image_count, image_model = failure.image_model, request_id = %failure.request_id.as_deref().unwrap_or_default(), error_source = %failure.error_source.as_deref().unwrap_or_default(), error = %failure.error_message, "外部 API 调用失败" ); } fn status_class(status_code: Option) -> &'static str { match status_code { Some(100..=199) => "1xx", Some(200..=299) => "2xx", Some(300..=399) => "3xx", Some(400..=499) => "4xx", Some(500..=599) => "5xx", Some(_) => "unknown", None => "transport", } } fn audit_request_context() -> crate::request_context::RequestContext { crate::request_context::RequestContext::new( format!("external-api-audit-{}", Uuid::new_v4()), "external-api audit".to_string(), std::time::Duration::ZERO, false, ) } fn truncate_field(value: &str, max_chars: usize) -> String { value.chars().take(max_chars).collect() } fn current_utc_iso_text() -> String { shared_kernel::format_rfc3339(OffsetDateTime::now_utc()) .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()) } #[cfg(test)] mod tests { use serde_json::Value; use super::*; #[test] fn bgfilter_outbox_only_policy_never_allows_sync_fallback() { assert!(ExternalApiAuditPersistencePolicy::PreferOutboxThenSync.allows_sync_fallback()); assert!( !ExternalApiAuditPersistencePolicy::RequireOutboxDropOnFailure.allows_sync_fallback() ); } #[test] fn external_api_failure_tracking_draft_uses_module_scope_and_safe_metadata() { let draft = build_external_api_failure_tracking_draft( &ExternalApiFailureDraft::new( "vector-engine", "https://vector.example/v1/images/generations", "拼图 UI 背景图生成失败", "upstream_status", "上游 429", ) .with_status_code(Some(429)) .with_retryable(true) .with_error_source(Some( "client error (SendRequest) -> connection closed before message completed" .to_string(), )) .with_latency_ms(Some(1234)) .with_prompt_chars(Some(88)) .with_reference_image_count(Some(2)) .with_image_model(Some("gpt-image-2-all")), ); assert_eq!(draft.event_key, EXTERNAL_API_FAILURE_EVENT_KEY); assert_eq!(draft.scope_kind, RuntimeTrackingScopeKind::Module); assert_eq!(draft.scope_id, "vector-engine"); assert_eq!(draft.module_key, Some(EXTERNAL_API_AUDIT_MODULE_KEY)); let metadata = draft.metadata; assert_eq!(metadata["provider"], "vector-engine"); assert_eq!(metadata["statusCode"], 429); assert_eq!(metadata["statusClass"], "4xx"); assert_eq!(metadata["retryable"], true); assert_eq!(metadata["latencyMs"], 1234); assert_eq!(metadata["promptChars"], 88); assert_eq!(metadata["referenceImageCount"], 2); assert_eq!(metadata["imageModel"], "gpt-image-2-all"); assert_eq!( metadata["errorSource"], "client error (SendRequest) -> connection closed before message completed" ); assert!(matches!(metadata["occurredAt"], Value::String(_))); } #[test] fn retryable_classification_keeps_transport_and_overload_failures_actionable() { assert!(is_retryable_external_api_failure(None, true, false)); assert!(is_retryable_external_api_failure(None, false, true)); assert!(is_retryable_external_api_failure(Some(429), false, false)); assert!(is_retryable_external_api_failure(Some(502), false, false)); assert!(!is_retryable_external_api_failure(Some(400), false, false)); } #[test] fn matting_failure_draft_marks_non_timeout_transport_retryable() { // status_code=None、timeout=false、transport=true 的非超时传输故障: // statusClass 必须是 transport 且 retryable=true,否则与 "transport failures actionable" 冲突。 let draft = build_matting_external_api_failure_draft( "bgfilter", "https://bgfilter.example/remove-background".to_string(), "editor-screen-background-removal", "bgfilter_segment", None, false, true, Some(67), "请求 BgFilter 服务失败:dns error".to_string(), Some("dns error".to_string()), &ExternalApiAuditContext::default(), ); assert_eq!(draft.status_code, None); assert_eq!(draft.status_class, Some("transport")); assert!(!draft.timeout); assert!( draft.retryable, "非超时 transport 失败应当 retryable=true,与 statusClass=transport 保持一致" ); } #[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,不能被误判为可重试。 let draft = build_matting_external_api_failure_draft( "aliyun-matting", "imageseg.example".to_string(), "editor-screen-background-removal", "aliyun_segment", Some(400), false, false, Some(12), "通用抠图接口返回失败(HTTP 400,Code=InvalidImage):bad image".to_string(), None, &ExternalApiAuditContext::default(), ); assert_eq!(draft.status_code, Some(400)); assert_eq!(draft.status_class, Some("4xx")); assert!(!draft.retryable); } #[test] fn app_error_status_class_can_override_successful_upstream_status() { let draft = build_external_api_failure_tracking_draft( &ExternalApiFailureDraft::new( "vector-engine", "https://cdn.example/generated.png", "下载生成图片", "image_download", "下载生成图片失败", ) .with_status_code(Some(200)) .with_optional_status_class(Some(app_error_status_class(StatusCode::BAD_GATEWAY))), ); 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", "latencyMs": 345, })); 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_latency_ms(matting_failure_audit_latency_ms(&error)) .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["latencyMs"], 345); 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); } #[test] fn matting_failure_audit_keeps_non_timeout_transport_classification() { let error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "bgfilter", "message": "请求 BgFilter 服务失败:dns error", "timeout": false, "transport": true, "latencyMs": 67, })); 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_latency_ms(matting_failure_audit_latency_ms(&error)) .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"], false); assert_eq!(tracking.metadata["retryable"], false); assert_eq!(tracking.metadata["latencyMs"], 67); } #[test] fn matting_failure_external_call_attempted_defaults_to_true() { let attempted = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "aliyun-matting", "message": "真实上游失败", })); let skipped = AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "aliyun-matting", "message": "阿里云抠图客户端未配置或未启用。", "externalCallAttempted": false, })); assert!(matting_failure_external_call_attempted(&attempted)); assert!(!matting_failure_external_call_attempted(&skipped)); } }