修复手动复杂去背景重试
手动 BGfilter complex 请求失败后自动重试一次。 补充 api-server 回归测试并记录重试决策。
This commit is contained in:
@@ -16,6 +16,14 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-15 手动复杂去背景复用 BgFilter 单次重试
|
||||
|
||||
- 背景:图片画布手动去背景已经改用 BgFilter `background_mode=complex`,但 worker 仍只发送一次上游请求,短暂网络抖动会直接让任务失败。
|
||||
- 决策:手动去背景的 complex 请求复用现有 `EDITOR_BGFILTER_RETRY_COUNT=1`,首次请求失败后立即重试一次,两次都失败仍返回最终错误;本次不把手动 complex 接入标准纯色背景链路的阿里云 / 本地兜底,也不改变 flat 路径的熔断状态。
|
||||
- 影响范围:图片画布手动去背景 worker、BgFilter complex 请求日志和 api-server 定向测试。
|
||||
- 验证方式:运行 `cargo test -p api-server editor_manual_background_removal_retries_once --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。
|
||||
- 关联文档:`docs/project-memory/shared-memory/decision-log.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
|
||||
## 2026-07-14 手动去背景迁移到 BgFilter complex 模式
|
||||
|
||||
- 背景:图片画布手动“去除背景”此前单独代理 BiRefNet 服务;BgFilter 已增加 `background_mode=complex`,可直接处理非纯色背景,继续保留独立服务会形成重复的上游、配置和错误处理链路。
|
||||
|
||||
@@ -2708,7 +2708,7 @@ pub(crate) async fn remove_editor_image_background_for_owner(
|
||||
payload.source_image_src.as_str(),
|
||||
)
|
||||
.await?;
|
||||
let removed = request_editor_background_removal_image(state, source_image).await?;
|
||||
let removed = request_editor_background_removal_image_with_retry(state, source_image).await?;
|
||||
let task_id = normalize_optional_string(payload.task_id.clone())
|
||||
.unwrap_or_else(|| format!("background-removal-{}", current_utc_micros()));
|
||||
let persisted = persist_editor_generated_image(
|
||||
@@ -2800,6 +2800,38 @@ struct EditorBackgroundRemovalImage {
|
||||
height: u32,
|
||||
}
|
||||
|
||||
async fn request_editor_background_removal_image_with_retry(
|
||||
state: &AppState,
|
||||
source_image: OpenAiReferenceImage,
|
||||
) -> Result<EditorBackgroundRemovalImage, AppError> {
|
||||
let max_attempts = EDITOR_BGFILTER_RETRY_COUNT + 1;
|
||||
let mut final_error = None;
|
||||
for attempt in 1..=max_attempts {
|
||||
match request_editor_background_removal_image(state, source_image.clone()).await {
|
||||
Ok(removed) => return Ok(removed),
|
||||
Err(error) => {
|
||||
let will_retry = attempt < max_attempts;
|
||||
tracing::warn!(
|
||||
provider = "bgfilter",
|
||||
background_mode = EDITOR_BGFILTER_BACKGROUND_MODE_COMPLEX,
|
||||
attempt,
|
||||
max_attempts,
|
||||
will_retry,
|
||||
error = %error,
|
||||
error_details = ?error.details(),
|
||||
"editor_background_removal_request_attempt_failed"
|
||||
);
|
||||
if will_retry {
|
||||
continue;
|
||||
}
|
||||
final_error = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(final_error.expect("BgFilter complex retry loop should retain its final error"))
|
||||
}
|
||||
|
||||
pub(crate) struct EditorScreenBackgroundRemovalOutput {
|
||||
pub(crate) image: DownloadedOpenAiImage,
|
||||
pub(crate) provider: &'static str,
|
||||
@@ -7000,6 +7032,99 @@ pub(crate) fn current_utc_micros() -> i64 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{config::AppConfig, editor_green_screen::parse_editor_screen_background_color};
|
||||
use std::{
|
||||
io::{Read as _, Write as _},
|
||||
net::{TcpListener, TcpStream},
|
||||
sync::mpsc,
|
||||
thread,
|
||||
};
|
||||
|
||||
fn encode_test_png(width: u32, height: u32) -> Vec<u8> {
|
||||
let image = image::DynamicImage::new_rgba8(width, height);
|
||||
let mut bytes = Cursor::new(Vec::new());
|
||||
image
|
||||
.write_to(&mut bytes, image::ImageFormat::Png)
|
||||
.expect("test PNG should encode");
|
||||
bytes.into_inner()
|
||||
}
|
||||
|
||||
fn read_mock_http_request(stream: &mut TcpStream) -> Vec<u8> {
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(1)))
|
||||
.expect("mock request read timeout should be set");
|
||||
let mut request = Vec::new();
|
||||
let mut chunk = [0_u8; 1024];
|
||||
let mut expected_total = None;
|
||||
|
||||
loop {
|
||||
match stream.read(&mut chunk) {
|
||||
Ok(0) => break,
|
||||
Ok(bytes_read) => {
|
||||
request.extend_from_slice(&chunk[..bytes_read]);
|
||||
if expected_total.is_none()
|
||||
&& let Some(header_end) = request
|
||||
.windows(4)
|
||||
.position(|window| window == b"\r\n\r\n")
|
||||
.map(|index| index + 4)
|
||||
{
|
||||
let headers = String::from_utf8_lossy(&request[..header_end]);
|
||||
let content_length = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length")
|
||||
.then(|| value.trim().parse::<usize>().ok())
|
||||
.flatten()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
expected_total = Some(header_end + content_length);
|
||||
}
|
||||
if expected_total.is_some_and(|total| request.len() >= total) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(error)
|
||||
if error.kind() == std::io::ErrorKind::WouldBlock
|
||||
|| error.kind() == std::io::ErrorKind::TimedOut =>
|
||||
{
|
||||
break;
|
||||
}
|
||||
Err(error) => panic!("mock server failed to read request: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
request
|
||||
}
|
||||
|
||||
fn spawn_bgfilter_png_mock(
|
||||
response_png: Vec<u8>,
|
||||
) -> (String, mpsc::Receiver<Vec<u8>>, thread::JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("mock listener should bind");
|
||||
let address = listener
|
||||
.local_addr()
|
||||
.expect("mock listener should expose address");
|
||||
let (request_sender, request_receiver) = mpsc::channel();
|
||||
let server = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("BgFilter request should connect");
|
||||
let request = read_mock_http_request(&mut stream);
|
||||
request_sender
|
||||
.send(request)
|
||||
.expect("captured request should be delivered");
|
||||
let headers = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nX-BgFilter-Elapsed-Ms: 9\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
response_png.len()
|
||||
);
|
||||
stream
|
||||
.write_all(headers.as_bytes())
|
||||
.expect("mock response headers should be written");
|
||||
stream
|
||||
.write_all(response_png.as_slice())
|
||||
.expect("mock response PNG should be written");
|
||||
stream.flush().expect("mock response should flush");
|
||||
});
|
||||
|
||||
(format!("http://{address}"), request_receiver, server)
|
||||
}
|
||||
|
||||
fn editor_project_resource_for_canvas_test(
|
||||
resource_id: &str,
|
||||
@@ -9324,6 +9449,58 @@ mod tests {
|
||||
assert_eq!(EDITOR_BGFILTER_BACKGROUND_MODE_COMPLEX, "complex");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_manual_background_removal_sends_complex_bgfilter_http_contract() {
|
||||
let response_png = encode_test_png(3, 2);
|
||||
let (base_url, request_receiver, server) = spawn_bgfilter_png_mock(response_png.clone());
|
||||
let state = AppState::new(AppConfig {
|
||||
editor_bgfilter_base_url: base_url,
|
||||
editor_bgfilter_token: Some("manual-complex-token".to_string()),
|
||||
editor_bgfilter_request_timeout_ms: 5_000,
|
||||
..AppConfig::default()
|
||||
})
|
||||
.expect("state should build");
|
||||
let source_image = OpenAiReferenceImage {
|
||||
bytes: encode_test_png(2, 2),
|
||||
mime_type: "image/png".to_string(),
|
||||
file_name: "manual-source.png".to_string(),
|
||||
};
|
||||
|
||||
let removed = request_editor_background_removal_image_with_retry(&state, source_image)
|
||||
.await
|
||||
.expect("BgFilter PNG response should decode");
|
||||
let request = request_receiver
|
||||
.recv_timeout(Duration::from_secs(1))
|
||||
.expect("mock server should capture request");
|
||||
server.join().expect("mock server should stop cleanly");
|
||||
let request_text = String::from_utf8_lossy(request.as_slice());
|
||||
let lowercase_request = request_text.to_ascii_lowercase();
|
||||
|
||||
assert!(request_text.starts_with("POST /remove-background HTTP/1.1\r\n"));
|
||||
assert!(
|
||||
lowercase_request.contains("\r\nx-genarrative-image-token: manual-complex-token\r\n")
|
||||
);
|
||||
assert!(request_text.contains("name=\"file\"; filename=\"manual-source.png\""));
|
||||
assert!(request_text.contains("name=\"background_mode\"\r\n\r\ncomplex\r\n"));
|
||||
assert!(request_text.contains("name=\"seg_model\"\r\n\r\nbirefnet\r\n"));
|
||||
assert!(request_text.contains("name=\"cross_check\"\r\n\r\noff\r\n"));
|
||||
assert!(!request_text.contains("name=\"screen_color\""));
|
||||
|
||||
assert_eq!((removed.width, removed.height), (3, 2));
|
||||
assert_eq!(removed.image.mime_type, "image/png");
|
||||
assert_eq!(removed.image.extension, "png");
|
||||
assert_eq!(removed.image.bytes, response_png);
|
||||
|
||||
let provider_error = decode_editor_background_removal_image(b"not an image")
|
||||
.expect_err("invalid BgFilter response should fail decoding");
|
||||
assert_eq!(
|
||||
provider_error
|
||||
.details()
|
||||
.and_then(|details| details.get("provider")),
|
||||
Some(&json!("bgfilter"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_bgfilter_circuit_opens_after_consecutive_failures_and_resets_on_success() {
|
||||
reset_editor_bgfilter_circuit_for_tests();
|
||||
@@ -9447,7 +9624,7 @@ mod tests {
|
||||
"struct EditorBackgroundRemovalImage",
|
||||
&[
|
||||
"caller.report_processing_phase(state).await?",
|
||||
"request_editor_background_removal_image",
|
||||
"request_editor_background_removal_image_with_retry",
|
||||
],
|
||||
);
|
||||
assert_function_contains(
|
||||
@@ -9628,6 +9805,25 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_manual_background_removal_retries_once() {
|
||||
let source = include_str!("editor_project.rs");
|
||||
assert_function_contains_in_order(
|
||||
source,
|
||||
"async fn request_editor_background_removal_image_with_retry",
|
||||
"async fn request_editor_background_removal_image(",
|
||||
&[
|
||||
"let max_attempts = EDITOR_BGFILTER_RETRY_COUNT + 1",
|
||||
"for attempt in 1..=max_attempts",
|
||||
"request_editor_background_removal_image(state, source_image.clone())",
|
||||
"let will_retry = attempt < max_attempts",
|
||||
"if will_retry",
|
||||
"continue",
|
||||
"final_error = Some(error)",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_paid_image_postprocess_keeps_provider_outputs_recoverable() {
|
||||
let source = include_str!("editor_project.rs");
|
||||
|
||||
Reference in New Issue
Block a user