diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 14993c62b..b4ba2bc9b 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -2476,12 +2476,16 @@ async fn request_editor_generated_screen_background_with_bgfilter( .get("x-bgfilter-screen-color") .and_then(|value| value.to_str().ok()) .map(ToOwned::to_owned); - let bytes = read_editor_image_removal_response_bytes(response, "bgfilter", "BgFilter").await?; + let bytes = + read_editor_image_removal_response_bytes(response, "bgfilter", "BgFilter", request_started_at) + .await?; if bytes.is_empty() { + // HTTP 已成功但 body 为空属于上游内容缺陷(5xx 归类正确),仍补 latencyMs 与 Aliyun 兜底口径对齐。 return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "bgfilter", "message": "BgFilter 服务未返回图片", + "latencyMs": request_started_at.elapsed().as_millis() as u64, })), ); } @@ -2607,7 +2611,7 @@ async fn request_editor_background_removal_image( .get("x-birefnet-elapsed-ms") .and_then(|value| value.to_str().ok()) .and_then(|value| value.parse::().ok()); - let bytes = read_editor_background_removal_bytes(response).await?; + let bytes = read_editor_background_removal_bytes(response, request_started_at).await?; if bytes.is_empty() { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ @@ -2723,14 +2727,17 @@ fn map_editor_bgfilter_error(error: reqwest::Error, latency_ms: u64) -> AppError async fn read_editor_background_removal_bytes( response: reqwest::Response, + request_started_at: Instant, ) -> Result, AppError> { - read_editor_image_removal_response_bytes(response, "birefnet", "抠图服务").await + read_editor_image_removal_response_bytes(response, "birefnet", "抠图服务", request_started_at) + .await } async fn read_editor_image_removal_response_bytes( mut response: reqwest::Response, provider: &str, service_label: &str, + request_started_at: Instant, ) -> Result, AppError> { if response.content_length().is_some_and(|content_length| { content_length > EDITOR_BACKGROUND_REMOVAL_MAX_RESPONSE_BYTES as u64 @@ -2744,11 +2751,16 @@ async fn read_editor_image_removal_response_bytes( ); } let mut bytes = Vec::new(); + // response.chunk() 失败属于「HTTP 状态已成功、读 body 时链路断裂」的传输层故障, + // 必须打上 transport/timeout/latencyMs/rawExcerpt,否则外部 API 失败审计会把它错记成 502/5xx。 while let Some(chunk) = response.chunk().await.map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": provider, - "message": format!("读取{service_label}结果失败:{error}"), - })) + editor_image_removal_body_read_error( + provider, + service_label, + error.is_timeout(), + request_started_at.elapsed().as_millis() as u64, + &error.to_string(), + ) })? { if bytes.len().saturating_add(chunk.len()) > EDITOR_BACKGROUND_REMOVAL_MAX_RESPONSE_BYTES { return Err( @@ -2764,6 +2776,31 @@ async fn read_editor_image_removal_response_bytes( Ok(bytes) } +/// 读取抠图上游响应体失败(response.chunk 中断)时构造的传输层失败错误。 +/// 与 `map_editor_bgfilter_error` 对齐:timeout 走 504、其余 502,均带 transport 标记以及 +/// latencyMs / rawExcerpt,供外部 API 失败审计正确归类为 transport 而非 5xx。 +fn editor_image_removal_body_read_error( + provider: &str, + service_label: &str, + is_timeout: bool, + latency_ms: u64, + error_text: &str, +) -> AppError { + let status = if is_timeout { + StatusCode::GATEWAY_TIMEOUT + } else { + StatusCode::BAD_GATEWAY + }; + AppError::from_status(status).with_details(json!({ + "provider": provider, + "message": format!("读取{service_label}结果失败:{error_text}"), + "timeout": is_timeout, + "transport": true, + "latencyMs": latency_ms, + "rawExcerpt": error_text, + })) +} + fn decode_editor_background_removal_image(bytes: &[u8]) -> Result { decode_editor_removed_background_image(bytes, "birefnet") } @@ -5666,6 +5703,52 @@ mod tests { ); } + #[test] + fn bgfilter_body_read_failure_audits_as_transport_not_5xx() { + // HTTP 状态已成功、但读 body 时链路断裂:外部 API 失败审计必须归类为 transport, + // 并保留 latencyMs / rawExcerpt,而不是被错记成 502/5xx。 + let error = editor_image_removal_body_read_error( + "bgfilter", + "BgFilter", + false, + 137, + "error reading a body from connection: connection reset", + ); + + assert_eq!(error.status_code(), StatusCode::BAD_GATEWAY); + assert_eq!( + crate::external_api_audit::matting_failure_audit_status_code(&error), + None, + "body-read 传输故障不应带 HTTP statusCode,否则会落成 5xx" + ); + assert!(!crate::external_api_audit::matting_failure_audit_timeout(&error)); + assert_eq!( + crate::external_api_audit::matting_failure_audit_latency_ms(&error), + Some(137) + ); + assert_eq!( + crate::external_api_audit::matting_failure_audit_raw_excerpt(&error).as_deref(), + Some("error reading a body from connection: connection reset") + ); + } + + #[test] + fn bgfilter_body_read_timeout_maps_to_gateway_timeout_and_transport() { + let error = + editor_image_removal_body_read_error("bgfilter", "BgFilter", true, 42, "operation timed out"); + + assert_eq!(error.status_code(), StatusCode::GATEWAY_TIMEOUT); + assert_eq!( + crate::external_api_audit::matting_failure_audit_status_code(&error), + None + ); + assert!(crate::external_api_audit::matting_failure_audit_timeout(&error)); + assert_eq!( + crate::external_api_audit::matting_failure_audit_latency_ms(&error), + Some(42) + ); + } + fn manual_screen_background_decision(hex: &str) -> EditorScreenBackgroundDecision { EditorScreenBackgroundDecision { color: parse_editor_screen_background_color(Some(hex))