修复非超时 transport 抠图失败被记为不可重试

record_matting_external_api_failure 计算 retryable 时把 connect(transport)
参数硬编码为 false,导致 DNS / 连接重置 / body-read 中断这类 status_code=None、
timeout=false 的传输失败落在 statusClass=transport 却 retryable=false,与
"transport failures actionable" 语义冲突,误导告警 / 重试分析。

- status_code=None 恰好等价于 statusClass=transport,据此派生 has_transport_error
  传入 retryable 计算,无需改签名或调用点。
- 抽出可测的 build_matting_external_api_failure_draft。
- 补回归测试:非超时 transport → transport ∧ retryable=true;上游 4xx → retryable=false。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 09:24:29 +00:00
parent 623afdcd3c
commit 00ce3a275c
@@ -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<String>,
) {
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<u16>,
timeout: bool,
latency_ms: Option<u64>,
error_message: String,
raw_excerpt: Option<String>,
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<u16> {
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 400Code=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(