From d015d07a0dd55ed1fe2108bc65268880bc71f750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=9F=8F=E9=9D=92?= Date: Tue, 16 Jun 2026 12:09:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(polish):=20retry=20=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E9=87=8D=E5=8F=91=E3=80=8C=E8=AF=B7=E6=B1=82=E4=BD=93=E5=86=99?= =?UTF-8?q?=E5=87=BA=E8=B6=85=E6=97=B6=E3=80=8D=E8=AF=B7=E6=B1=82=EF=BC=8C?= =?UTF-8?q?=E6=9D=9C=E7=BB=9D=E9=87=8D=E5=A4=8D=E8=AE=A1=E8=B4=B9=20(#680)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reqwest 会把请求体写出阶段的超时同时标记为 is_request()+is_timeout(), 原 match 臂先判 `is_connect() || is_request()`,会先命中并重试,与函数自身 文档(timeout 故意不重试,因服务端可能已收到并对非幂等 LLM completion 计费) 相悖,可能重发请求 → 重复 completion + 重复插入 + 双重计费。 抽出纯函数 should_retry_transient(is_connect, is_request, is_timeout) 并加 `&& !is_timeout()` 守卫,使「任何带 timeout 标记的失败都不重试」,与 net.rs 的 策略一致;附穷举真值表单测。 --- openless-all/app/src-tauri/src/polish.rs | 34 +++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index e80f9b8f..0271b0e4 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -1250,6 +1250,14 @@ pub(crate) fn http_client_builder(base_url: &str, timeout_secs: u64) -> reqwest: } } +/// 是否对一次发送失败做「网络抖动」重试。**只**对「服务端必然没收到」的 connect / +/// request 类失败重试;任何 `is_timeout()` 都不重试——非幂等的 LLM completion 在请求体 +/// 写出阶段超时时(reqwest 会把这类错误同时标记成 `is_request()` + `is_timeout()`),服务端 +/// 可能已收到并计费,重试会导致重复 billing + 重复 completion。抽成纯函数便于穷举单测。 +fn should_retry_transient(is_connect: bool, is_request: bool, is_timeout: bool) -> bool { + (is_connect || is_request) && !is_timeout +} + /// 发请求 + 网络抖动 retry:**只**对 `is_connect()` / `is_request()` 这两类「服务端 /// 必然没收到」的失败重试一次。`is_timeout()` 故意**不**重试——超时时服务端可能已经 /// 在处理请求并扣计费(LLM completion 是非幂等动作),重试会导致重复 billing + 重复 @@ -1277,7 +1285,7 @@ async fn send_with_transient_retry( }; match initial.send().await { Ok(r) => Ok(r), - Err(e) if e.is_connect() || e.is_request() => { + Err(e) if should_retry_transient(e.is_connect(), e.is_request(), e.is_timeout()) => { log::warn!( "[llm] send transient failure, retry in {}ms: {}", RETRY_DELAY_MS, @@ -2464,6 +2472,30 @@ mod tests { static CODEX_AUTH_FIXTURE_COUNTER: AtomicU64 = AtomicU64::new(0); static ENV_LOCK: StdMutex<()> = StdMutex::new(()); + #[test] + fn retries_connect_and_request_failures_without_timeout() { + // 纯 connect / 纯 request 失败(服务端必然没收到)→ 重试一次。 + assert!(should_retry_transient(true, false, false)); + assert!(should_retry_transient(false, true, false)); + assert!(should_retry_transient(true, true, false)); + } + + #[test] + fn never_retries_when_timeout_flagged() { + // 关键回归:请求体写出阶段超时,reqwest 同时标记 is_request + is_timeout—— + // 服务端可能已收到并对非幂等 LLM completion 计费,绝不能重试(否则重复扣费)。 + assert!(!should_retry_transient(false, true, true)); + assert!(!should_retry_transient(true, false, true)); + assert!(!should_retry_transient(true, true, true)); + // 纯超时同样不重试。 + assert!(!should_retry_transient(false, false, true)); + } + + #[test] + fn does_not_retry_unrelated_failures() { + assert!(!should_retry_transient(false, false, false)); + } + struct EnvSnapshot { values: Vec<(&'static str, Option)>, }