修复: 连接池收敛与传输错误分类重试

- 连接池空闲十五秒,陈旧连接复用致重置根治
- 流式发送错误改谓词分类,openai 路径不再裸奔
- decode 类失败纳入重试,正文中断可自愈
- 状态分类合并响应体,激活休眠词表分支
- 手动压缩失败降级兜底,不再弹红卡
This commit is contained in:
lxy
2026-10-01 19:35:22 +08:00
parent 4417b3ddd3
commit 7dcbdb1feb
5 changed files with 343 additions and 21 deletions
+85 -5
View File
@@ -502,7 +502,9 @@ impl LlmProvider for OpenAICompatProvider {
let resp = match resp {
Ok(r) => r,
Err(e) => {
if is_reqwest_error_retryable(&e) {
// is_decode 补可重试:body 中途断连在 reqwest 归 decode 类,complete 重发
// 幂等安全(受 3 次/30s 预算约束,真正格式错重试同败,预算内止损)。
if is_reqwest_error_retryable(&e) || e.is_decode() {
return AttemptOutcome::Retryable(format!("请求失败(可重试): {}", e));
}
return AttemptOutcome::Fatal(format!("请求失败(不可重试): {}", e));
@@ -520,10 +522,16 @@ impl LlmProvider for OpenAICompatProvider {
error!(%status, %body, "LLM API 调用失败(不可重试)");
return AttemptOutcome::Fatal(msg);
}
// body 解析: 解析错属 Fatal(响应已成功送达,重试也会因同样格式失败)
// body 解析: serde 结构错属 Fatal(响应已成功送达,重试同样失败);
// body 读取中途断连在 reqwest 同归 is_decode,重发幂等安全 → 可重试。
let body: OpenAiResponse = match resp.json().await {
Ok(b) => b,
Err(e) => return AttemptOutcome::Fatal(format!("响应解析失败: {}", e)),
Err(e) => {
if e.is_decode() {
return AttemptOutcome::Retryable(format!("响应解码失败(可重试): {}", e));
}
return AttemptOutcome::Fatal(format!("响应解析失败: {}", e));
}
};
let choice = match body.choices.into_iter().next() {
Some(c) => c,
@@ -570,8 +578,23 @@ impl LlmProvider for OpenAICompatProvider {
let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
tracing::error!(error = %e, is_timeout = e.is_timeout(), "OpenAI 流式 send 失败");
return Err(e.into());
// 谓词结构化分类:Display 串不含 source 关键词("error sending request for url"),
// 纯串分类必 unknown → Fatal 零重试(陈旧连接首包 RST 就此裸奔)。类词置串首,
// source 链兜底真实传输词(reset/broken pipe 等),下游按类词走可重试。
let class = crate::send_error_class(crate::SendErrPreds::of(&e));
tracing::error!(
error = %e,
class = class,
url = %self.chat_url(),
source = ?std::error::Error::source(&e),
"OpenAI 流式 send 失败"
);
anyhow::bail!(
"流式 send 失败({}): {} [src={:?}]",
class,
e,
std::error::Error::source(&e)
);
}
Err(_elapsed) => {
tracing::error!(url = %self.chat_url(), "OpenAI 流式 send 超时(60s 未返回响应头)");
@@ -1279,4 +1302,61 @@ mod tests {
let tc4 = c4.tool_calls.as_ref().expect("应有 tool_calls").first().unwrap();
assert_eq!(tc4.id.as_deref(), Some("call_xyz"), "非空 id 原样透传");
}
// ---------- 流式 send 错误分类(谓词纯函数,reqwest::Error 无公开构造器以快照模拟) ----------
/// send 阶段陈旧连接首包 RST(reqwest 形态 is_request=true + 源 ConnectionReset)
/// → connect 类词 → 下游可重试。根治 Display 串无 source 关键词被分 unknown 零重试。
#[test]
fn send_error_connection_reset_is_retryable_class() {
let class = crate::send_error_class(crate::SendErrPreds {
is_request: true,
..Default::default()
});
assert_eq!(class, "connect");
}
/// timeout/connect/decode/body/request 形态各归本类,且类词均须落在下游传输可重试
/// 词表内(镜像 src-tauri stream_recv classify_status_or_class 词表,改任一侧需同步)。
#[test]
fn send_error_transport_classes_are_retryable_words() {
const RETRYABLE_WORDS: &[&str] = &[
"timeout",
"connect",
"transport",
"decoding",
"decode",
"body",
"eof",
"reset",
"broken pipe",
];
let cases = [
crate::SendErrPreds { is_timeout: true, ..Default::default() },
crate::SendErrPreds { is_connect: true, ..Default::default() },
crate::SendErrPreds { is_decode: true, ..Default::default() },
crate::SendErrPreds { is_body: true, ..Default::default() },
crate::SendErrPreds { is_request: true, ..Default::default() },
];
for p in cases {
let class = crate::send_error_class(p);
assert!(
RETRYABLE_WORDS.contains(&class),
"类词 {} 应在传输可重试词表内",
class
);
}
}
/// builder 构造错(请求本体问题,重试同败)→ request 类词,不在词表 → 下游 Fatal,
/// 与 retry::is_reqwest_error_retryable 的 is_builder 排除口径一致。
#[test]
fn send_error_builder_is_fatal_class() {
let class = crate::send_error_class(crate::SendErrPreds {
is_request: true,
is_builder: true,
..Default::default()
});
assert_eq!(class, "request");
}
}