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 190a68685..9f9417e49 100644 --- a/server-rs/crates/api-server/src/external_api_audit.rs +++ b/server-rs/crates/api-server/src/external_api_audit.rs @@ -149,6 +149,7 @@ pub(crate) struct ExternalApiAuditContext { /// 抠图供应商(BgFilter / 阿里云通用抠图)调用失败的统一失败审计入口。 /// 即使随后兜底成功,供应商故障也必须进入 OTLP + tracking_event,不能只 warn! 后静默。 +#[allow(clippy::too_many_arguments)] pub(crate) async fn record_matting_external_api_failure( state: &AppState, context: &ExternalApiAuditContext, @@ -162,22 +163,52 @@ pub(crate) async fn record_matting_external_api_failure( error_message: String, raw_excerpt: Option, ) { - 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_latency_ms(latency_ms) - .with_raw_excerpt(raw_excerpt) - .with_audit_context(context); + let draft = build_matting_external_api_failure_draft( + provider, + endpoint, + operation, + failure_stage, + status_code, + timeout, + latency_ms, + error_message, + raw_excerpt, + context, + ); record_external_api_failure(state, draft).await; } +#[allow(clippy::too_many_arguments)] +fn build_matting_external_api_failure_draft( + provider: &'static str, + endpoint: String, + operation: &'static str, + failure_stage: &'static str, + status_code: Option, + timeout: bool, + latency_ms: Option, + error_message: String, + raw_excerpt: Option, + 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(); + 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, + has_transport_error, + )) + .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 { error .details() @@ -534,6 +565,53 @@ mod tests { 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 失败(DNS / 连接重置 / 读体中断): + // 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, + 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_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, + 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(