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