修复非超时 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:
@@ -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 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(
|
||||
|
||||
Reference in New Issue
Block a user