From eb5430dc4ae906bed627f594b137e794d98d9cd7 Mon Sep 17 00:00:00 2001 From: baiqing Date: Fri, 15 May 2026 11:37:34 +0800 Subject: [PATCH 1/2] fix(llm): retry transient network errors once before failing polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈日志显示 streaming polish 偶发失败: [coord] streaming polish FAILED: network error: error sending request for url (https://api.deepseek.com/v1/chat/completions) 诊断:失败发生在 reqwest `request.send().await` 阶段(不是 HTTP 4xx/5xx), 属于 connect / request / timeout 三类 transient 错误。同一 session 内大量 polish 调用成功,证明网络没断、是间歇性抖动。 修法:抽 `send_with_transient_retry` helper,首次失败 + 错误是 transient (is_connect / is_request / is_timeout)→ sleep 500ms 重试一次。retry 第二次 失败按原 LLMError::Timeout / Network 返回。HTTP 4xx/5xx 走 response.status() 分支不受影响。 适用范围:3 处 reqwest send 都用 helper: - chat_completion_messages_streaming (SSE 流式,line 720) - chat_completion_messages_streaming 的非流式兄弟 (line 591) - send_chat_request 一次性 chat 调用 (line 517) retry 安全前提:传入 RequestBuilder body 必须是内存型(json/form),用 `try_clone()` 复制;3 处都满足。对流式路径 retry 安全是因为失败发生在 SSE 开始前 → on_delta 必然未被调用 → 不会重复输出。 注:Codex OAuth 路径(line 1085)使用独立 client,结构不同,本 PR 不动; 后续如有相同抖动反馈再扩展。 cargo test 263 全过。 --- openless-all/app/src-tauri/src/polish.rs | 76 +++++++++++++++--------- 1 file changed, 49 insertions(+), 27 deletions(-) diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index a6adbdfc..3ec14f8a 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -514,15 +514,7 @@ impl OpenAICompatibleLLMProvider { } let request = request.json(body); - let response = match request.send().await { - Ok(r) => r, - Err(e) => { - if e.is_timeout() { - return Err(LLMError::Timeout); - } - return Err(LLMError::Network(e.to_string())); - } - }; + let response = send_with_transient_retry(request).await?; let status = response.status(); let body_text = response @@ -588,15 +580,7 @@ impl OpenAICompatibleLLMProvider { } let request = request.json(&body); - let response = match request.send().await { - Ok(r) => r, - Err(e) => { - if e.is_timeout() { - return Err(LLMError::Timeout); - } - return Err(LLMError::Network(e.to_string())); - } - }; + let response = send_with_transient_retry(request).await?; let status = response.status(); if !status.is_success() { @@ -717,15 +701,7 @@ impl OpenAICompatibleLLMProvider { } let request = request.json(&body); - let response = match request.send().await { - Ok(r) => r, - Err(e) => { - if e.is_timeout() { - return Err(LLMError::Timeout); - } - return Err(LLMError::Network(e.to_string())); - } - }; + let response = send_with_transient_retry(request).await?; let status = response.status(); if !status.is_success() { @@ -1260,6 +1236,52 @@ pub(crate) fn http_client_builder(base_url: &str, timeout_secs: u64) -> reqwest: } } +/// 发请求 + 网络抖动 retry:connect / request / timeout 三类 transient 错误首次失败时 +/// 等 500ms 重试一次,再失败按原样返回。HTTP 4xx/5xx 不在这里触发——那些走 response +/// 的 status 分支单独处理。 +/// +/// 调用前提:传入的 RequestBuilder body 必须是内存型(json / form),不能是 stream +/// reader——retry 用 `try_clone()` 复制 RequestBuilder,stream body 不支持。 +/// +/// 对流式 SSE 路径 retry 是安全的:失败发生在 `send().await` 阶段,response 还没回 +/// 来 → on_delta 必然未被调用 → 不会有「已流式输出的字被重复」的问题。 +async fn send_with_transient_retry( + request: reqwest::RequestBuilder, +) -> Result { + const RETRY_DELAY_MS: u64 = 500; + let initial = request + .try_clone() + .expect("memory-backed body (json/form) must be clonable for retry"); + match initial.send().await { + Ok(r) => Ok(r), + Err(e) if e.is_connect() || e.is_request() || e.is_timeout() => { + log::warn!( + "[llm] send transient failure, retry in {}ms: {}", + RETRY_DELAY_MS, + e + ); + tokio::time::sleep(Duration::from_millis(RETRY_DELAY_MS)).await; + match request.send().await { + Ok(r) => Ok(r), + Err(e2) => { + if e2.is_timeout() { + Err(LLMError::Timeout) + } else { + Err(LLMError::Network(e2.to_string())) + } + } + } + } + Err(e) => { + if e.is_timeout() { + Err(LLMError::Timeout) + } else { + Err(LLMError::Network(e.to_string())) + } + } + } +} + fn should_bypass_proxy_for_base_url(base_url: &str) -> bool { let Ok(url) = reqwest::Url::parse(base_url.trim()) else { return false; From 55c0c64671279044aca366e34bb42abff7c6755d Mon Sep 17 00:00:00 2001 From: baiqing Date: Fri, 15 May 2026 11:43:39 +0800 Subject: [PATCH 2/2] fix(llm): don't retry on timeout to avoid duplicate billing (pr_agent #443 round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pr_agent #443 review 指出 Duplicate Request 风险: > Retrying after send() fails can submit the same LLM request twice if the > first attempt already reached the provider but the connection dropped > before a response was returned. For non-idempotent completion calls, that > can mean duplicate billing and two completions for one user action. 事实分析 reqwest::Error 各 variant: - is_connect() → TCP 握手没建立,server 不可能收到 → 安全 retry - is_request() → HTTP 请求层错误(构造问题),server 没收到完整请求 → 安全 retry - is_timeout() → client 设置的 timeout 到了,server 可能已经收到并在处理 (非幂等 completion 调用)→ 不安全 retry,会重复 billing 修法:从 retry 触发条件移除 `|| e.is_timeout()`。timeout 直接返回 LLMError::Timeout,不再重试。 user 实际看到的失败模式是 "error sending request"(reqwest::is_connect),不在 被移除范围内,覆盖率不变。 注:pr_agent 同轮提的 "Ticket compliance ❌ Not compliant 442" 是 false positive —— pr_agent 把 PR description 里提到的 #442 当成本 PR 的 ticket id;#442 是另一 个独立 PR(A-D 默认值 / 提示词重构),已 merge。 cargo test 263 全过。 --- openless-all/app/src-tauri/src/polish.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index 3ec14f8a..38a8ca7c 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -1236,15 +1236,17 @@ pub(crate) fn http_client_builder(base_url: &str, timeout_secs: u64) -> reqwest: } } -/// 发请求 + 网络抖动 retry:connect / request / timeout 三类 transient 错误首次失败时 -/// 等 500ms 重试一次,再失败按原样返回。HTTP 4xx/5xx 不在这里触发——那些走 response -/// 的 status 分支单独处理。 +/// 发请求 + 网络抖动 retry:**只**对 `is_connect()` / `is_request()` 这两类「服务端 +/// 必然没收到」的失败重试一次。`is_timeout()` 故意**不**重试——超时时服务端可能已经 +/// 在处理请求并扣计费(LLM completion 是非幂等动作),重试会导致重复 billing + 重复 +/// completion。HTTP 4xx/5xx 不在这里触发——那些走 response.status() 分支单独处理。 /// /// 调用前提:传入的 RequestBuilder body 必须是内存型(json / form),不能是 stream /// reader——retry 用 `try_clone()` 复制 RequestBuilder,stream body 不支持。 /// -/// 对流式 SSE 路径 retry 是安全的:失败发生在 `send().await` 阶段,response 还没回 -/// 来 → on_delta 必然未被调用 → 不会有「已流式输出的字被重复」的问题。 +/// 对流式 SSE 路径 retry 是安全的:connect / request 类失败发生在 TCP 握手 / HTTP +/// 请求写出阶段,response 还没回 → on_delta 必然未被调用 → 不会有「已流式输出的字 +/// 被重复」的问题。 async fn send_with_transient_retry( request: reqwest::RequestBuilder, ) -> Result { @@ -1254,7 +1256,7 @@ async fn send_with_transient_retry( .expect("memory-backed body (json/form) must be clonable for retry"); match initial.send().await { Ok(r) => Ok(r), - Err(e) if e.is_connect() || e.is_request() || e.is_timeout() => { + Err(e) if e.is_connect() || e.is_request() => { log::warn!( "[llm] send transient failure, retry in {}ms: {}", RETRY_DELAY_MS,