diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index a6adbdf..38a8ca7 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,54 @@ pub(crate) fn http_client_builder(base_url: &str, timeout_secs: u64) -> reqwest: } } +/// 发请求 + 网络抖动 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 是安全的:connect / request 类失败发生在 TCP 握手 / HTTP +/// 请求写出阶段,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() => { + 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;