diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8c5a31e72..2bb7bef68 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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`,可直接处理非纯色背景,继续保留独立服务会形成重复的上游、配置和错误处理链路。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index beed46c31..411784d88 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -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 { + 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 { + 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 { + 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::().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, + ) -> (String, mpsc::Receiver>, 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");