抠图错误分类下沉到 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 429Code=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 400Code=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 429Code=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 429Code=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));
}
}
+244 -60
View File
@@ -143,7 +143,20 @@ pub enum MattingError {
InvalidConfig(String),
InvalidRequest(String),
Sign(String),
Upstream(String),
Upstream(UpstreamFailure),
}
/// 结构化的上游调用失败分类。协议层归属(是否传输层故障、是否超时、上游 HTTP 状态)由
/// platform-matting 在错误发生处直接捕获,调用方(api-server BFF)不再从中文 message 反推。
#[derive(Debug)]
pub struct UpstreamFailure {
message: String,
/// 传输层超时(reqwest `is_timeout`)。
timeout: bool,
/// 传输层故障:请求已发出但未拿到有效 HTTP 响应(连接失败、读体中断、超时)。
transport: bool,
/// 上游返回的 HTTP 状态码;`None` 表示没拿到状态(传输层故障或响应体不可用)。
upstream_status: Option<u16>,
}
impl MattingError {
@@ -151,10 +164,65 @@ impl MattingError {
match self {
Self::InvalidConfig(message)
| Self::InvalidRequest(message)
| Self::Sign(message)
| Self::Upstream(message) => message,
| Self::Sign(message) => message,
Self::Upstream(failure) => &failure.message,
}
}
/// 是否真正向阿里云发起过外部调用。`InvalidConfig` / `InvalidRequest` / `Sign` 都是发请求前的
/// 本地预检失败,未触达上游。
pub fn external_call_attempted(&self) -> bool {
matches!(self, Self::Upstream(_))
}
/// 上游调用是否为传输层超时。
pub fn is_timeout(&self) -> bool {
matches!(self, Self::Upstream(failure) if failure.timeout)
}
/// 上游调用是否为传输层故障(未拿到有效 HTTP 响应)。
pub fn is_transport(&self) -> bool {
matches!(self, Self::Upstream(failure) if failure.transport)
}
/// 上游返回的 HTTP 状态码(若有)。
pub fn upstream_status(&self) -> Option<u16> {
match self {
Self::Upstream(failure) => failure.upstream_status,
_ => None,
}
}
/// 传输层故障构造器:请求已发出但没拿到有效 HTTP 响应(连接、读体、超时)。
pub fn upstream_transport_error(message: String, timeout: bool) -> Self {
Self::Upstream(UpstreamFailure {
message,
timeout,
transport: true,
upstream_status: None,
})
}
/// 上游返回了 HTTP 响应但状态非成功。
pub fn upstream_http_error(message: String, status: u16) -> Self {
Self::Upstream(UpstreamFailure {
message,
timeout: false,
transport: false,
upstream_status: Some(status),
})
}
/// 已拿到 HTTP 响应,但响应体 / 结果不可用(JSON 非法、缺字段、结果图不可解码 / 尺寸不符、
/// 结果编码失败等)。不是传输层故障,也不归因某个错误状态码。
pub fn upstream_response_error(message: String, upstream_status: Option<u16>) -> Self {
Self::Upstream(UpstreamFailure {
message,
timeout: false,
transport: false,
upstream_status,
})
}
}
impl std::fmt::Display for MattingError {
@@ -234,17 +302,28 @@ impl MattingClient {
.body(payload)
.send()
.await
.map_err(|error| MattingError::Upstream(format!("通用抠图请求失败:{error}")))?;
.map_err(|error| {
MattingError::upstream_transport_error(
format!("通用抠图请求失败:{error}"),
error.is_timeout(),
)
})?;
let http_status = response.status();
let body_text = response.text().await.map_err(|error| {
MattingError::Upstream(format!("通用抠图响应读取失败:{error}"))
MattingError::upstream_transport_error(
format!("通用抠图响应读取失败:{error}"),
error.is_timeout(),
)
})?;
let body: serde_json::Value = serde_json::from_str(&body_text).map_err(|error| {
MattingError::Upstream(format!(
"通用抠图响应不是合法 JSON{error};原始响应:{}",
truncate_for_log(&body_text)
))
MattingError::upstream_response_error(
format!(
"通用抠图响应不是合法 JSON{error};原始响应:{}",
truncate_for_log(&body_text)
),
Some(http_status.as_u16()),
)
})?;
let request_id = body
@@ -263,12 +342,15 @@ impl MattingClient {
provider_request_id = request_id.as_deref().unwrap_or("unknown"),
"阿里云通用抠图接口返回失败"
);
return Err(MattingError::Upstream(format!(
"通用抠图接口返回失败(HTTP {}Code={}):{}",
return Err(MattingError::upstream_http_error(
format!(
"通用抠图接口返回失败(HTTP {}Code={}):{}",
http_status.as_u16(),
code.unwrap_or("unknown"),
message.unwrap_or("unknown")
),
http_status.as_u16(),
code.unwrap_or("unknown"),
message.unwrap_or("unknown")
)));
));
}
let result_image_url = body
@@ -277,10 +359,13 @@ impl MattingClient {
.and_then(|value| value.as_str())
.map(|value| value.to_string())
.ok_or_else(|| {
MattingError::Upstream(format!(
"通用抠图响应缺少 Data.ImageURL;原始响应:{}",
truncate_for_log(&body_text)
))
MattingError::upstream_response_error(
format!(
"通用抠图响应缺少 Data.ImageURL;原始响应:{}",
truncate_for_log(&body_text)
),
Some(http_status.as_u16()),
)
})?;
info!(
@@ -337,13 +422,16 @@ impl MattingClient {
if !downscaled {
if result_image.dimensions() != (source_width, source_height) {
return Err(MattingError::Upstream(format!(
"抠图结果尺寸 {}x{} 与输入 {}x{} 不一致",
result_image.width(),
result_image.height(),
source_width,
source_height
)));
return Err(MattingError::upstream_response_error(
format!(
"抠图结果尺寸 {}x{} 与输入 {}x{} 不一致",
result_image.width(),
result_image.height(),
source_width,
source_height
),
None,
));
}
return encode_rgba_png(&result_image);
}
@@ -369,14 +457,17 @@ impl MattingClient {
.send()
.await
.map_err(|error| {
MattingError::Upstream(describe_result_download_transport_error(&error))
MattingError::upstream_transport_error(
describe_result_download_transport_error(&error),
error.is_timeout(),
)
})?;
let status = response.status();
if !status.is_success() {
return Err(MattingError::Upstream(format!(
"下载抠图结果失败(HTTP {}",
status.as_u16()
)));
return Err(MattingError::upstream_http_error(
format!("下载抠图结果失败(HTTP {}", status.as_u16()),
status.as_u16(),
));
}
// 先按 Content-Length 快速拒绝,再流式累加做兜底:不信任上游声明的长度,
// 逐块累计超阈值立即中断,避免 response.bytes() 一次性分配任意大小响应撑爆内存。
@@ -388,7 +479,10 @@ impl MattingClient {
}
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|error| {
MattingError::Upstream(describe_result_download_body_error(&error))
MattingError::upstream_transport_error(
describe_result_download_body_error(&error),
error.is_timeout(),
)
})? {
if bytes.len().saturating_add(chunk.len()) > MAX_RESULT_RESPONSE_BYTES {
return Err(result_response_too_large_error());
@@ -457,18 +551,24 @@ impl MattingClient {
.send()
.await
.map_err(|error| {
MattingError::Upstream(format!("上传 VIAPI 临时桶请求失败:{error}"))
MattingError::upstream_transport_error(
format!("上传 VIAPI 临时桶请求失败:{error}"),
error.is_timeout(),
)
})?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
// OSS 签名类错误体会回显 StringToSign(含 x-oss-security-token 明文)、StringToSignBytes
// 和 SignatureProvided;脱敏后再记录,避免 STS 临时凭证进审计元数据与日志。
return Err(MattingError::Upstream(format!(
"上传 VIAPI 临时桶失败(HTTP {}):{}",
return Err(MattingError::upstream_http_error(
format!(
"上传 VIAPI 临时桶失败(HTTP {}):{}",
status.as_u16(),
sanitize_oss_upload_error_body(&body, &sts.security_token)
),
status.as_u16(),
sanitize_oss_upload_error_body(&body, &sts.security_token)
)));
));
}
Ok(target_url)
@@ -498,39 +598,59 @@ impl MattingClient {
.body(payload)
.send()
.await
.map_err(|error| MattingError::Upstream(format!("GetOssStsToken 请求失败:{error}")))?;
.map_err(|error| {
MattingError::upstream_transport_error(
format!("GetOssStsToken 请求失败:{error}"),
error.is_timeout(),
)
})?;
let http_status = response.status();
let body_text = response.text().await.map_err(|error| {
MattingError::Upstream(format!("GetOssStsToken 响应读取失败:{error}"))
MattingError::upstream_transport_error(
format!("GetOssStsToken 响应读取失败:{error}"),
error.is_timeout(),
)
})?;
let body: serde_json::Value = serde_json::from_str(&body_text).map_err(|error| {
MattingError::Upstream(format!(
"GetOssStsToken 响应不是合法 JSON{error};原始响应:{}",
truncate_for_log(&body_text)
))
MattingError::upstream_response_error(
format!(
"GetOssStsToken 响应不是合法 JSON{error};原始响应:{}",
truncate_for_log(&body_text)
),
Some(http_status.as_u16()),
)
})?;
if http_status != StatusCode::OK {
return Err(MattingError::Upstream(format!(
"GetOssStsToken 返回失败(HTTP {}Code={}):{}",
return Err(MattingError::upstream_http_error(
format!(
"GetOssStsToken 返回失败(HTTP {}Code={}):{}",
http_status.as_u16(),
body.get("Code").and_then(|value| value.as_str()).unwrap_or("unknown"),
body.get("Message")
.and_then(|value| value.as_str())
.unwrap_or("unknown")
),
http_status.as_u16(),
body.get("Code").and_then(|value| value.as_str()).unwrap_or("unknown"),
body.get("Message")
.and_then(|value| value.as_str())
.unwrap_or("unknown")
)));
));
}
let data = body.get("Data").ok_or_else(|| {
MattingError::Upstream(format!(
"GetOssStsToken 响应缺少 Data;原始响应:{}",
truncate_for_log(&body_text)
))
MattingError::upstream_response_error(
format!(
"GetOssStsToken 响应缺少 Data;原始响应:{}",
truncate_for_log(&body_text)
),
Some(http_status.as_u16()),
)
})?;
let read_field = |name: &str| -> Result<String, MattingError> {
data.get(name)
.and_then(|value| value.as_str())
.map(|value| value.to_string())
.ok_or_else(|| {
MattingError::Upstream(format!("GetOssStsToken 响应缺少 Data.{name}"))
MattingError::upstream_response_error(
format!("GetOssStsToken 响应缺少 Data.{name}"),
Some(http_status.as_u16()),
)
})
};
Ok(ViapiStsToken {
@@ -641,14 +761,20 @@ fn encode_rgba_png(image: &image::RgbaImage) -> Result<Vec<u8>, MattingError> {
image.height(),
image::ExtendedColorType::Rgba8,
)
.map_err(|error| MattingError::Upstream(format!("编码抠图结果 PNG 失败:{error}")))?;
.map_err(|error| {
MattingError::upstream_response_error(
format!("编码抠图结果 PNG 失败:{error}"),
None,
)
})?;
Ok(encoded)
}
fn result_response_too_large_error() -> MattingError {
MattingError::Upstream(format!(
"抠图结果响应过大,超过 {MAX_RESULT_RESPONSE_BYTES} 字节上限"
))
MattingError::upstream_response_error(
format!("抠图结果响应过大,超过 {MAX_RESULT_RESPONSE_BYTES} 字节上限"),
None,
)
}
/// 解码待抠图源图时套上尺寸 / 分配上限。源图直接来自请求体 / 生成产物,缺省
@@ -661,8 +787,9 @@ fn decode_source_image(bytes: &[u8]) -> Result<image::DynamicImage, MattingError
/// 解码抠图结果时套上尺寸 / 分配上限,防止上游用巨幅尺寸声明在解码阶段撑爆内存。
fn decode_result_image(bytes: &[u8]) -> Result<image::DynamicImage, MattingError> {
decode_image_within_limits(bytes)
.map_err(|error| MattingError::Upstream(format!("解析抠图结果图片失败:{error}")))
decode_image_within_limits(bytes).map_err(|error| {
MattingError::upstream_response_error(format!("解析抠图结果图片失败:{error}"), None)
})
}
/// 统一的有界解码:尺寸上限 `MAX_DECODE_IMAGE_DIMENSION`、分配上限
@@ -858,6 +985,63 @@ fn truncate_for_log(text: &str) -> String {
mod tests {
use super::*;
#[test]
fn local_preflight_variants_report_no_external_call() {
for error in [
MattingError::InvalidConfig("endpoint 为空".to_string()),
MattingError::InvalidRequest("待抠图图片尺寸过小".to_string()),
MattingError::Sign("初始化签名器失败".to_string()),
] {
assert!(!error.external_call_attempted(), "{}", error.message());
assert!(!error.is_timeout());
assert!(!error.is_transport());
assert_eq!(error.upstream_status(), None);
}
}
#[test]
fn upstream_transport_error_classifies_as_transport() {
let error =
MattingError::upstream_transport_error("通用抠图请求失败:dns error".to_string(), false);
assert!(error.external_call_attempted());
assert!(error.is_transport());
assert!(!error.is_timeout());
assert_eq!(error.upstream_status(), None);
}
#[test]
fn upstream_transport_error_preserves_timeout_flag() {
let error =
MattingError::upstream_transport_error("通用抠图请求失败:timed out".to_string(), true);
assert!(error.external_call_attempted());
assert!(error.is_transport());
assert!(error.is_timeout());
}
#[test]
fn upstream_http_error_carries_status_and_is_not_transport() {
let error = MattingError::upstream_http_error(
"通用抠图接口返回失败(HTTP 429Code=Throttled".to_string(),
429,
);
assert!(error.external_call_attempted());
assert!(!error.is_transport());
assert!(!error.is_timeout());
assert_eq!(error.upstream_status(), Some(429));
}
#[test]
fn upstream_response_error_is_external_but_not_transport() {
let error = MattingError::upstream_response_error(
"抠图结果尺寸不一致".to_string(),
None,
);
assert!(error.external_call_attempted());
assert!(!error.is_transport());
assert!(!error.is_timeout());
assert_eq!(error.upstream_status(), None);
}
#[test]
fn form_body_is_sorted_and_url_encoded() {
let mut params = BTreeMap::new();