抠图错误分类下沉到 platform-matting,BFF 不再从中文 message 反推

外部供应商协议归属应留在 platform-* 层。此前 api-server 从中文错误字符串
contains("timeout") / find("HTTP ") 反推超时、HTTP 状态与 transport,违反分层。
- MattingError::Upstream 改为结构化 UpstreamFailure,携带 timeout/transport/
  upstream_status,在错误发生处(reqwest is_timeout、HTTP 状态、响应体不可用)直接捕获
- 新增 external_call_attempted/is_timeout/is_transport/upstream_status 访问器
- 20 处 Upstream 构造点改用 upstream_transport_error/upstream_http_error/
  upstream_response_error,分类语义不变
- aliyun_matting.rs 删除三个字符串解析 helper,直接读结构化分类;审计 JSON 键
  (timeout/transport/upstreamStatus/externalCallAttempted)完全不变,无功能损失
- 分类单测下沉到 platform-matting,BFF 侧改测映射

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 09:16:32 +00:00
parent 04094d2665
commit a701fa0240
2 changed files with 306 additions and 156 deletions
@@ -46,62 +46,39 @@ pub(crate) async fn segment_image_with_aliyun_matting(
/// 把 platform-matting 的错误映射成审计友好的 AppError。
///
/// 关键区分:`InvalidConfig` / `InvalidRequest` / `Sign` 都是发请求前的本地预检失败
/// (客户端未配置、图片解码失败、尺寸过小、签名构造失败),此时根本没调用阿里云,必须
/// 标记 `externalCallAttempted=false`、`transport=false`,否则会被误写成可重试的供应商
/// transport 故障审计。只有 `Upstream` 才是真实发生过的外部调用失败。
/// 分类(是否外部调用、超时、传输层故障、上游 HTTP 状态)由 platform-matting 在错误发生处
/// 结构化捕获,这里只做协议中立的读取,不再从中文 message 反推——外部供应商协议归属留在
/// platform-* 层。`InvalidConfig` / `InvalidRequest` / `Sign` 是发请求前的本地预检失败,
/// `external_call_attempted()` 为 false,据此跳过外部失败审计。
fn aliyun_matting_failure_to_app_error(error: &MattingError, latency_ms: u64) -> AppError {
let message = error.message();
match error {
MattingError::Upstream(_) => {
let timeout = aliyun_matting_error_is_timeout(message);
let upstream_status = aliyun_matting_error_http_status(message);
let status = if timeout {
StatusCode::GATEWAY_TIMEOUT
} else {
StatusCode::BAD_GATEWAY
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-matting",
"message": message,
"timeout": timeout,
"transport": aliyun_matting_error_is_transport(message),
"upstreamStatus": upstream_status,
"latencyMs": latency_ms,
"rawExcerpt": message.chars().take(500).collect::<String>(),
}))
}
MattingError::InvalidConfig(_) | MattingError::InvalidRequest(_) | MattingError::Sign(_) => {
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "aliyun-matting",
"message": message,
"timeout": false,
"transport": false,
"externalCallAttempted": false,
"latencyMs": latency_ms,
"rawExcerpt": message.chars().take(500).collect::<String>(),
}))
}
if !error.external_call_attempted() {
// 本地预检失败(未配置 / 图片解码失败 / 尺寸过小 / 签名构造失败),未触达阿里云。
return AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "aliyun-matting",
"message": message,
"timeout": false,
"transport": false,
"externalCallAttempted": false,
"latencyMs": latency_ms,
"rawExcerpt": message.chars().take(500).collect::<String>(),
}));
}
}
fn aliyun_matting_error_is_timeout(message: &str) -> bool {
let normalized = message.to_ascii_lowercase();
normalized.contains("timeout") || normalized.contains("timed out")
}
fn aliyun_matting_error_http_status(message: &str) -> Option<u16> {
let marker = "HTTP ";
let start = message.find(marker)? + marker.len();
let digits = message[start..]
.chars()
.take_while(|value| value.is_ascii_digit())
.collect::<String>();
digits.parse().ok()
}
fn aliyun_matting_error_is_transport(message: &str) -> bool {
aliyun_matting_error_http_status(message).is_none()
let timeout = error.is_timeout();
let status = if timeout {
StatusCode::GATEWAY_TIMEOUT
} else {
StatusCode::BAD_GATEWAY
};
AppError::from_status(status).with_details(json!({
"provider": "aliyun-matting",
"message": message,
"timeout": timeout,
"transport": error.is_transport(),
"upstreamStatus": error.upstream_status(),
"latencyMs": latency_ms,
"rawExcerpt": message.chars().take(500).collect::<String>(),
}))
}
fn aliyun_matting_unconfigured_error() -> AppError {
@@ -116,44 +93,6 @@ fn aliyun_matting_unconfigured_error() -> AppError {
mod tests {
use super::*;
#[test]
fn aliyun_matting_error_http_status_extracts_provider_status() {
assert_eq!(
aliyun_matting_error_http_status(
"通用抠图接口返回失败(HTTP 429,Code=Throttled):QPS exceeded"
),
Some(429)
);
assert_eq!(
aliyun_matting_error_http_status("下载抠图结果失败(HTTP 403)"),
Some(403)
);
assert_eq!(aliyun_matting_error_http_status("网络失败"), None);
}
#[test]
fn aliyun_matting_error_timeout_detects_transport_timeout() {
assert!(aliyun_matting_error_is_timeout(
"通用抠图请求失败:operation timed out"
));
assert!(aliyun_matting_error_is_timeout(
"GetOssStsToken 请求失败:request timeout"
));
assert!(!aliyun_matting_error_is_timeout(
"通用抠图接口返回失败(HTTP 400,Code=InvalidImage):bad image"
));
}
#[test]
fn aliyun_matting_error_transport_requires_missing_http_status() {
assert!(aliyun_matting_error_is_transport(
"通用抠图请求失败:dns error"
));
assert!(!aliyun_matting_error_is_transport(
"通用抠图接口返回失败(HTTP 429,Code=Throttled):QPS exceeded"
));
}
#[test]
fn aliyun_matting_unconfigured_error_marks_no_external_call_attempt() {
let error = aliyun_matting_unconfigured_error();
@@ -185,15 +124,42 @@ mod tests {
}
#[test]
fn upstream_failure_still_counts_as_external_call() {
let error = MattingError::Upstream(
"通用抠图请求失败:dns error".to_string(),
);
fn upstream_transport_failure_maps_to_retryable_transport() {
let error =
MattingError::upstream_transport_error("通用抠图请求失败:dns error".to_string(), false);
let mapped = aliyun_matting_failure_to_app_error(&error, 12);
assert!(crate::external_api_audit::matting_failure_external_call_attempted(&mapped));
let details = mapped.details().expect("details present");
// 无 HTTP 状态的上游失败仍应标记为可重试 transport 故障。
// 无 HTTP 状态的传输层失败标记为可重试 transport 故障,且不带 upstreamStatus。
assert_eq!(details.get("transport").and_then(|v| v.as_bool()), Some(true));
assert_eq!(details.get("timeout").and_then(|v| v.as_bool()), Some(false));
assert!(details.get("upstreamStatus").is_some_and(|v| v.is_null()));
}
#[test]
fn upstream_timeout_failure_maps_to_gateway_timeout() {
let error =
MattingError::upstream_transport_error("通用抠图请求失败:timed out".to_string(), true);
let mapped = aliyun_matting_failure_to_app_error(&error, 7);
assert_eq!(mapped.status_code(), StatusCode::GATEWAY_TIMEOUT);
let details = mapped.details().expect("details present");
assert_eq!(details.get("timeout").and_then(|v| v.as_bool()), Some(true));
assert_eq!(details.get("transport").and_then(|v| v.as_bool()), Some(true));
}
#[test]
fn upstream_http_status_failure_carries_status_without_transport() {
let error = MattingError::upstream_http_error(
"通用抠图接口返回失败(HTTP 429,Code=Throttled):QPS exceeded".to_string(),
429,
);
let mapped = aliyun_matting_failure_to_app_error(&error, 5);
assert!(crate::external_api_audit::matting_failure_external_call_attempted(&mapped));
let details = mapped.details().expect("details present");
assert_eq!(details.get("transport").and_then(|v| v.as_bool()), Some(false));
assert_eq!(details.get("upstreamStatus").and_then(|v| v.as_u64()), Some(429));
}
}