修复手动复杂去背景重试

手动 BGfilter complex 请求失败后自动重试一次。

补充 api-server 回归测试并记录重试决策。
This commit is contained in:
2026-07-15 07:38:23 +00:00
parent d927c74cee
commit dd55834884
2 changed files with 206 additions and 2 deletions
@@ -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");