fix(llm): retry transient network errors once before failing polish

用户反馈日志显示 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 全过。
This commit is contained in:
baiqing committed 2026-05-15 11:37:34 +08:00
1 parent e297bfdd6e
commit eb5430dc4a
1 file changed
+49 -27
+49 -27
View File
@@ -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<reqwest::Response, LLMError> {
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;