修复 BgFilter body-read 失败被误记为 502/5xx

response.chunk() 在 HTTP 状态已成功后中断,属于「读 body 时链路断裂」
的传输层故障,但原先只抛 BAD_GATEWAY+message,外部 API 失败审计取不到
transport/timeout/upstreamStatus,回落 Some(502) → statusClass 5xx,
漏掉了 transport 场景。

- 新增 editor_image_removal_body_read_error:timeout 走 504、其余 502,
  统一带 transport/timeout/latencyMs/rawExcerpt,审计正确归类为 transport。
- read_editor_image_removal_response_bytes 加 request_started_at,BgFilter
  与 birefnet 两条读体路径都透传,chunk 失败可算出 latencyMs。
- BgFilter 空 body 补 latencyMs,与 Aliyun 兜底口径对齐。
- 补两个回归测试覆盖 body-read 传输故障与 timeout 分类。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 09:16:27 +00:00
parent 158d073392
commit 623afdcd3c
@@ -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::<u64>().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<Vec<u8>, 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<Vec<u8>, 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<image::DynamicImage, AppError> {
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))