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:
1 parent
e297bfdd6e
commit
eb5430dc4a
1 file changed
+49
-27
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user