抠图错误分类下沉到 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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user