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

- 连接池空闲十五秒,陈旧连接复用致重置根治
- 流式发送错误改谓词分类,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
+18 -3
View File
@@ -600,7 +600,9 @@ impl LlmProvider for AnthropicCompatProvider {
source = ?std::error::Error::source(&e), source = ?std::error::Error::source(&e),
"Anthropic 同步 send 失败" "Anthropic 同步 send 失败"
); );
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::Retryable(format!("请求失败(可重试): {}", e));
} }
return AttemptOutcome::Fatal(format!( return AttemptOutcome::Fatal(format!(
@@ -626,7 +628,13 @@ impl LlmProvider for AnthropicCompatProvider {
} }
let resp: AnthropicResponse = match resp.json().await { let resp: AnthropicResponse = match resp.json().await {
Ok(r) => r, Ok(r) => r,
Err(e) => return AttemptOutcome::Fatal(format!("响应解析失败: {}", e)), // serde 结构错属 Fatal(重试同败);body 读取中途断连同归 is_decode,可重试。
Err(e) => {
if e.is_decode() {
return AttemptOutcome::Retryable(format!("响应解码失败(可重试): {}", e));
}
return AttemptOutcome::Fatal(format!("响应解析失败: {}", e));
}
}; };
// content 块中拼接 text,收集 tool_use // content 块中拼接 text,收集 tool_use
let mut text = String::new(); let mut text = String::new();
@@ -714,17 +722,24 @@ impl LlmProvider for AnthropicCompatProvider {
let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await { let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await {
Ok(Ok(r)) => r, Ok(Ok(r)) => r,
Ok(Err(e)) => { Ok(Err(e)) => {
// 谓词结构化分类(对齐 openai 路径,共用 crate::send_error_class)。原 bail 串
// [timeout={} connect={}=...] 的字面词让一切错误(含 builder 错)侥幸可重试。
// 类词改由谓词决定置串首;谓词转储缩写为 t/c/b/r 防误触分类词表,source 链
// 保留真实传输词(reset/broken pipe 等)作串兜底。
let class = crate::send_error_class(crate::SendErrPreds::of(&e));
tracing::error!( tracing::error!(
is_timeout = e.is_timeout(), is_timeout = e.is_timeout(),
is_connect = e.is_connect(), is_connect = e.is_connect(),
is_body = e.is_body(), is_body = e.is_body(),
is_request = e.is_request(), is_request = e.is_request(),
class = class,
url = %self.messages_url(), url = %self.messages_url(),
source = ?std::error::Error::source(&e), source = ?std::error::Error::source(&e),
"Anthropic 流式 send 失败" "Anthropic 流式 send 失败"
); );
anyhow::bail!( anyhow::bail!(
"send 失败: {} [timeout={} connect={} body={} request={} src={:?}]", "send 失败({}): {} [t={} c={} b={} r={} src={:?}]",
class,
e, e,
e.is_timeout(), e.is_timeout(),
e.is_connect(), e.is_connect(),
+57
View File
@@ -64,6 +64,11 @@ pub use df_ai_core;
pub(crate) fn build_provider_client() -> Client { pub(crate) fn build_provider_client() -> Client {
Client::builder() Client::builder()
.connect_timeout(std::time::Duration::from_secs(30)) .connect_timeout(std::time::Duration::from_secs(30))
// 连接池收敛:idle 连接 15s 弃用 + 每主机最多存 2 条。reqwest 默认 idle 90s 且
// 无上限,服务端/前置 keepalive 更短时复用陈旧连接首包即被 RST(ConnectionReset
// 10054),呈现「隔几十秒一发就失败」的间歇形态;15s 保守低于常见网关 60s keepalive。
.pool_idle_timeout(std::time::Duration::from_secs(15))
.pool_max_idle_per_host(2)
.build() .build()
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
tracing::warn!("reqwest builder 失败,回退默认 client: {}", e); tracing::warn!("reqwest builder 失败,回退默认 client: {}", e);
@@ -71,6 +76,58 @@ pub(crate) fn build_provider_client() -> Client {
}) })
} }
/// 流式 send 阶段 reqwest 错误的谓词快照。
///
/// reqwest::Error 无公开构造器,抽谓词结构体过纯函数,单测才能锁定分类
/// (send 阶段 ConnectionReset → 可重试类词)。
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct SendErrPreds {
pub is_timeout: bool,
pub is_connect: bool,
pub is_decode: bool,
pub is_body: bool,
pub is_request: bool,
pub is_builder: bool,
}
impl SendErrPreds {
pub(crate) fn of(e: &reqwest::Error) -> Self {
Self {
is_timeout: e.is_timeout(),
is_connect: e.is_connect(),
is_decode: e.is_decode(),
is_body: e.is_body(),
is_request: e.is_request(),
is_builder: e.is_builder(),
}
}
}
/// 流式 send 阶段错误分类,返回类词(置于 bail 串首位供下游按串分类)。
///
/// reqwest 0.12 的 Display 只给 "error sending request for url",不含 source 关键词,
/// 纯 Display 串分类必得 unknown → Fatal 零重试。此处用谓词确定性给类词:
/// timeout/connect/decode/body 各归本类;is_request(非 builder)覆盖陈旧池化连接
/// 首包 RST(ConnectionReset 10054)等传输层发送错,归 connect(与 retry 的
/// is_reqwest_error_retryable 对 is_request 可重试口径一致);builder 构造错归
/// request(不可重试)。类词须落在 stream_recv classify_status_or_class 传输词表内,
/// 改任一侧需同步。
pub(crate) fn send_error_class(p: SendErrPreds) -> &'static str {
if p.is_timeout {
"timeout"
} else if p.is_connect {
"connect"
} else if p.is_decode {
"decode"
} else if p.is_body {
"body"
} else if p.is_request && !p.is_builder {
"connect"
} else {
"request"
}
}
/// 按 provider_type 构建 LLM Provider 实例(统一选择逻辑,消除调用方重复 match) /// 按 provider_type 构建 LLM Provider 实例(统一选择逻辑,消除调用方重复 match)
/// ///
/// `anthropic` 协议走 AnthropicCompatProvider(GLM 订阅端点 / Claude 官方), /// `anthropic` 协议走 AnthropicCompatProvider(GLM 订阅端点 / Claude 官方),
+85 -5
View File
@@ -502,7 +502,9 @@ impl LlmProvider for OpenAICompatProvider {
let resp = match resp { let resp = match resp {
Ok(r) => r, Ok(r) => r,
Err(e) => { 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::Retryable(format!("请求失败(可重试): {}", e));
} }
return AttemptOutcome::Fatal(format!("请求失败(不可重试): {}", e)); return AttemptOutcome::Fatal(format!("请求失败(不可重试): {}", e));
@@ -520,10 +522,16 @@ impl LlmProvider for OpenAICompatProvider {
error!(%status, %body, "LLM API 调用失败(不可重试)"); error!(%status, %body, "LLM API 调用失败(不可重试)");
return AttemptOutcome::Fatal(msg); return AttemptOutcome::Fatal(msg);
} }
// body 解析: 解析错属 Fatal(响应已成功送达,重试也会因同样格式失败) // body 解析: serde 结构错属 Fatal(响应已成功送达,重试同样失败);
// body 读取中途断连在 reqwest 同归 is_decode,重发幂等安全 → 可重试。
let body: OpenAiResponse = match resp.json().await { let body: OpenAiResponse = match resp.json().await {
Ok(b) => b, 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() { let choice = match body.choices.into_iter().next() {
Some(c) => c, Some(c) => c,
@@ -570,8 +578,23 @@ impl LlmProvider for OpenAICompatProvider {
let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await { let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await {
Ok(Ok(r)) => r, Ok(Ok(r)) => r,
Ok(Err(e)) => { Ok(Err(e)) => {
tracing::error!(error = %e, is_timeout = e.is_timeout(), "OpenAI 流式 send 失败"); // 谓词结构化分类:Display 串不含 source 关键词("error sending request for url"),
return Err(e.into()); // 纯串分类必 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) => { Err(_elapsed) => {
tracing::error!(url = %self.chat_url(), "OpenAI 流式 send 超时(60s 未返回响应头)"); 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(); let tc4 = c4.tool_calls.as_ref().expect("应有 tool_calls").first().unwrap();
assert_eq!(tc4.id.as_deref(), Some("call_xyz"), "非空 id 原样透传"); 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");
}
} }
+127 -11
View File
@@ -666,8 +666,51 @@ pub async fn ai_chat_send(
); );
} }
} }
// 共用核心收口(先例 execute_run_workflow_for_tool):provider 解析 → conv 占用检查 →
// push user 消息 → system prompt 组装 → loop_epoch → spawn run_agentic_loop。
// IPC 返回值语义不变:Ok 映射回 "ok"(前端契约),conv_id 仅供编排节点侧消费。
spawn_agentic_session(
&app,
&state,
message,
language,
skill,
model_override,
conversation_id,
parts,
mention_spans,
true,
)
.await
.map(|_| "ok".to_string())
}
/// 发送消息并启动 agentic 会话的共用核心(纯搬迁自 ai_chat_send,行为逐字等价)。
///
/// 调用方:ai_chat_send IPC(前端对话,pin_active=true)/ 工作流 AiAgentNode 编排会话
/// (orchestration_session_spawner,pin_active=false)。两方共用同一 provider 解析/conv 占用/
/// system prompt 组装/loop_epoch/spawn 链路,不重复实现。
///
/// `pin_active` 唯一行为开关:true 时保持原「active_conversation_id 同步到 target」语义
/// (用户当前面板即 target);false(编排会话,显式传独立新 conv_id)跳过同步,不劫持用户
/// 活跃面板。其余路径与原实现逐字一致。
///
/// 返回 Ok(conv_id):编排节点用它追踪会话/等待完成通知;ai_chat_send 映射回 "ok"。
#[allow(clippy::too_many_arguments)]
pub(crate) async fn spawn_agentic_session(
app: &AppHandle,
state: &AppState,
message: String,
language: Option<String>,
skill: Option<String>,
model_override: Option<String>,
conversation_id: Option<String>,
parts: Option<Vec<ContentPart>>,
mention_spans: Option<Vec<MentionSpanDto>>,
pin_active: bool,
) -> Result<String, String> {
// 获取活跃提供商(只读,失败可直接返回,不影响生成标志) // 获取活跃提供商(只读,失败可直接返回,不影响生成标志)
let provider_config = super::super::prompt::get_active_provider(&state).await?; let provider_config = super::super::prompt::get_active_provider(state).await?;
// anthropic 协议需 url→base64 预拉(base64 内嵌,URL 不可达;OpenAI 兼容原生 URL 直传零改动)。 // anthropic 协议需 url→base64 预拉(base64 内嵌,URL 不可达;OpenAI 兼容原生 URL 直传零改动)。
// 拉取在 lock 之前做,不持锁网络 I/O;结果供 lock 内 user_parts 使用。 // 拉取在 lock 之前做,不持锁网络 I/O;结果供 lock 内 user_parts 使用。
@@ -704,7 +747,8 @@ pub async fn ai_chat_send(
}; };
// active_conversation_id 同步到 target(用户当前面板即 target,路由键跟随)。 // active_conversation_id 同步到 target(用户当前面板即 target,路由键跟随)。
// 决策 e:旧 conv 的后台 loop 不受 active 变更影响(写自己的 per_conv)。 // 决策 e:旧 conv 的后台 loop 不受 active 变更影响(写自己的 per_conv)。
if session.active_conversation_id.as_deref() != Some(target.as_str()) { // pin_active=false(工作流编排会话)跳过:编排 conv 不劫持用户活跃面板。
if pin_active && session.active_conversation_id.as_deref() != Some(target.as_str()) {
session.active_conversation_id = Some(target.clone()); session.active_conversation_id = Some(target.clone());
// 切到已有 conv(非本次懒建)时,确保其 active_conv_created_at 已设(switch 路径已设)。 // 切到已有 conv(非本次懒建)时,确保其 active_conv_created_at 已设(switch 路径已设)。
if session.active_conv_created_at.is_none() { if session.active_conv_created_at.is_none() {
@@ -827,6 +871,8 @@ pub async fn ai_chat_send(
let app_handle = app.clone(); let app_handle = app.clone();
let knowledge_config = state.knowledge_config.lock().await.clone(); let knowledge_config = state.knowledge_config.lock().await.clone();
let llm_concurrency = state.llm_concurrency.clone(); let llm_concurrency = state.llm_concurrency.clone();
// 返回值用 clone(conv_id 本体 move 进 spawn 闭包,搬运自原实现保持不动)
let conv_id_return = conv_id.clone();
// loop 入口 load 快照,当前 loop 锁定边界(热改下次发消息生效) // loop 入口 load 快照,当前 loop 锁定边界(热改下次发消息生效)
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst); let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
// 流式失败重试次数快照 // 流式失败重试次数快照
@@ -842,7 +888,53 @@ pub async fn ai_chat_send(
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch).await; run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch).await;
}); });
Ok("ok".to_string()) // 返回 conv_id(共用核心:IPC 层映射回 "ok",编排节点消费 conv_id 等待完成通知)
Ok(conv_id_return)
}
/// 工作流 AiAgentNode 的编排会话启动通道(节点工厂经 build_registry 注入,见 state.rs)。
///
/// 流程:取进程级 AppHandle(spawn_agent 子代理同款 OnceLock,lib.rs setup 注入)→ 生成
/// 新独立 conv_id(不与用户活跃会话共用,防 epoch 互踩/active 面板劫持)→ 注册完成通知桥
/// (AppState.agent_completion_bridge)→ 复用 [`spawn_agentic_session`] 起独立 agentic 会话
/// → 返回 (conv_id, 完成通知接收端)。
///
/// 注册先于 spawn:完成通知最早发生在 loop 收敛出口(秒级之后),不存在注册前触发竞态;
/// 顺序对调则有「先收敛后注册」丢通知窗口。返回的接收端由节点 select! 等待;发送端挂在
/// 通知桥,agentic loop 收敛统一出口(emit_ai_completed_once)投递最终 assistant 报告。
pub(crate) fn orchestration_session_spawner() -> df_nodes::ai_agent_node::SpawnFn {
std::sync::Arc::new(|task: String| -> df_nodes::ai_agent_node::SpawnFuture {
Box::pin(async move {
let app = super::super::tools::spawn_agent::app_handle()
.ok_or_else(|| "应用句柄未就绪(启动初始化未完成)".to_string())?;
let conv_id = new_id();
let (tx, rx) = tokio::sync::oneshot::channel::<String>();
let state = app.state::<AppState>();
state.register_agent_completion(conv_id.clone(), tx);
match spawn_agentic_session(
&app,
&state,
task,
// 编排会话:无语言/技能/模型覆盖/多模态/@mention;pin_active=false 不动用户活跃面板
None,
None,
None,
Some(conv_id.clone()),
None,
None,
false,
)
.await
{
Ok(_) => Ok((conv_id, rx)),
// 启动失败:接收端随本 Err 返回被丢弃,通知桥条目同步清掉(防残留死条目)
Err(e) => {
state.notify_agent_completion(&conv_id, String::new());
Err(e)
}
}
})
})
} }
/// 批准/拒绝挂起的工具调用 /// 批准/拒绝挂起的工具调用
@@ -1608,12 +1700,14 @@ pub async fn ai_chat_clear_context(
/// 手动 LLM 压缩上下文(F-15 阶段2):摘要保护区外消息并插入首位。 /// 手动 LLM 压缩上下文(F-15 阶段2):摘要保护区外消息并插入首位。
/// ///
/// 流程(失败不阻塞,消息状态不变): /// 流程(失败非阻断降级):
/// 1. emit AiCompressing + set_compressing(true) 防重入 /// 1. emit AiCompressing + set_compressing(true) 防重入
/// 2. 算 compress_end(保护区外,同 clear_context 的 protect_start) /// 2. 算 compress_end(保护区外,同 clear_context 的 protect_start)
/// 3. 先取保护区外 active 消息的克隆(读不改)→ 喂 compress_via_llm /// 3. 先取保护区外 active 消息的克隆(读不改)→ 喂 compress_via_llm
/// 4. LLM 成功 → compress_old_messages 标 compressed(扣 token) + insert_at(0, system 摘要) /// 4. LLM 成功 → compress_old_messages 标 compressed(扣 token) + insert_at(0, system 摘要)
/// 5. LLM 失败 → set_compressing(false) + emit AiError(不含 api_key) + 返回 Err;消息状态不变 /// 5. LLM 失败 → 与自动压缩同款兜底:标 compressed 释放 token + 关键词摘要插首位 +
/// toast(AiManualCompressed),不发 AiError 红卡(前端 AiError 无条件复位 streaming,
/// 手动压缩失败也非生成期错误)
/// ///
/// 空会话/无 active 可压缩消息 → set_compressing(false) 后 emit AiCompressing noop 返回 Ok。 /// 空会话/无 active 可压缩消息 → set_compressing(false) 后 emit AiCompressing noop 返回 Ok。
#[tauri::command] #[tauri::command]
@@ -1707,6 +1801,9 @@ pub async fn ai_chat_compress_context(
} }
}; };
let llm_concurrency = state.llm_concurrency.clone(); let llm_concurrency = state.llm_concurrency.clone();
// LLM 失败兜底文本须在 active_msgs move 进 compress_via_llm 前算好
// (同 context_lifecycle 自动压缩先算兜底再喂 LLM 的次序)。
let keyword_fallback = df_ai::context_helpers::extract_keyword_summary(&active_msgs);
let summary = match super::super::compress::compress_via_llm( let summary = match super::super::compress::compress_via_llm(
provider.as_ref(), provider.as_ref(),
&provider_config, &provider_config,
@@ -1717,19 +1814,38 @@ pub async fn ai_chat_compress_context(
).await { ).await {
Ok(s) => s.nl_summary, Ok(s) => s.nl_summary,
Err(e) => { Err(e) => {
// LLM 失败:不阻塞,set_compressing(false),消息状态不变(未调 compress_old_messages) // LLM 失败非阻断降级(与自动压缩 context_lifecycle 同款兜底):标 compressed 释放
// token + 关键词摘要插首位作续接锚点,不再 emit AiError 红卡(前端 AiError 无条件
// 复位 streaming 误判生成结束,且手动压缩失败非生成期错误)。toast 走
// AiManualCompressed 告知兜底结果。
tracing::warn!(
conv_id = %conv_id,
error = %e,
fallback_len = keyword_fallback.len(),
"[ai] 手动压缩 LLM 失败,降级关键词摘要兜底(非阻断)"
);
let __lock_t1295 = std::time::Instant::now(); let __lock_t1295 = std::time::Instant::now();
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
session.conv(&conv_id).messages.set_compressing(false); let conv = session.conv(&conv_id);
let _compressed = conv.messages.compress_old_messages(compress_end);
if !keyword_fallback.is_empty() {
conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback));
}
conv.messages.set_compressing(false);
log_slow_lock(__lock_t1295, "ai_chat_compress_context:1295"); log_slow_lock(__lock_t1295, "ai_chat_compress_context:1295");
drop(session); drop(session);
let ev = AiChatEvent::AiError { save_conversation(&state.ai_session, &state.db, &conv_id, None, None, false).await;
error: format!("压缩失败: {}", e), let toast_summary = if keyword_fallback.is_empty() {
error_type: Some(super::super::ErrorType::Unknown), "压缩失败,历史消息已直接释放(可重试)".to_string()
} else {
keyword_fallback.clone()
};
let ev = AiChatEvent::AiManualCompressed {
conversation_id: Some(conv_id), conversation_id: Some(conv_id),
summary: toast_summary,
}; };
emit_ai_chat_event(&app, ev); emit_ai_chat_event(&app, ev);
return Err(format!("压缩失败: {}", e)); return Ok(());
} }
}; };
+56 -2
View File
@@ -582,7 +582,8 @@ pub(crate) async fn stream_llm(
/// - HTTP 429 / 5xx → retryable=true(复用 retry::is_status_retryable 逻辑) /// - HTTP 429 / 5xx → retryable=true(复用 retry::is_status_retryable 逻辑)
/// - HTTP 4xx(非429) → retryable=false Fatal(401/403/404/422 等) /// - HTTP 4xx(非429) → retryable=false Fatal(401/403/404/422 等)
/// - timeout / connect → retryable=true(瞬态,可重试) /// - timeout / connect → retryable=true(瞬态,可重试)
/// - unknown / 其他 → retryable=true(保守可重试,避免误判 Fatal 错杀) /// - unknown → 交 classify_status_with_body 合并 body 按传输关键词(reset/decode/eof 等)判定,
/// 无命中则 Fatal(对齐 retry::is_status_retryable 未知不可重试语义)
/// ///
/// 抽公共分类函数(决策 F-260616-07 a1 "复用 retry.rs 错误分类逻辑或镜像"), /// 抽公共分类函数(决策 F-260616-07 a1 "复用 retry.rs 错误分类逻辑或镜像"),
/// 避免 stream_recv 与 agentic 各自重写状态码解析。 /// 避免 stream_recv 与 agentic 各自重写状态码解析。
@@ -614,7 +615,17 @@ fn classify_status_or_class(status_or_class: &str) -> bool {
// BUG-2026-07-17 根治:SSE 解码/传输错误(error decoding response body / Transport error) // BUG-2026-07-17 根治:SSE 解码/传输错误(error decoding response body / Transport error)
// 是传输层错误,不是 Fatal。原实现未识别这些关键词,返 unknown→Fatal,致连续重试 4 次均 // 是传输层错误,不是 Fatal。原实现未识别这些关键词,返 unknown→Fatal,致连续重试 4 次均
// 立即放弃。现补全传输层关键词,让这类错误走可重试路径。 // 立即放弃。现补全传输层关键词,让这类错误走可重试路径。
let lower = status_or_class.to_lowercase(); transport_keyword_hit(&status_or_class.to_lowercase())
}
/// 传输层可重试关键词判定(小写文本)。
///
/// 词表与 df-ai crate::send_error_class 的类词契约对应(改任一侧需同步)。
/// 抽独立纯函数供 classify_status_or_class 与 classify_status_with_body 的 unknown 分支共用:
/// body 原文常含任意数字串(os error 10054/时间戳),过上方三位码扫描器会被误当 HTTP 码
/// (如 "100" → is_status_retryable(100)=false)——这正是历史 reset/decode 关键词分支
/// 永远够不着的第二层死因,unknown 类需绕过扫描器直接走词表。
fn transport_keyword_hit(lower: &str) -> bool {
lower.contains("timeout") lower.contains("timeout")
|| lower.contains("connect") || lower.contains("connect")
|| lower.contains("transport") || lower.contains("transport")
@@ -647,10 +658,19 @@ fn is_quota_exhaustion(combined: &str) -> bool {
/// 额度耗尽型 429 → Fatal(确定性,不重试);否则交 classify_status_or_class 正常判定。 /// 额度耗尽型 429 → Fatal(确定性,不重试);否则交 classify_status_or_class 正常判定。
/// 入参 body:错误体原文(provider bail 文本 / error 帧 message)。 /// 入参 body:错误体原文(provider bail 文本 / error 帧 message)。
///
/// 传输层关键词(reset/decode/eof/broken pipe 等)只出现在 body 原文,而 status_or_class
/// 经 extract_error_diag_from_str 只会是 HTTP 码/timeout/connect/unknown——只喂 status 时
/// 关键词分支永远够不着(dead code,历史修复未生效)。unknown(无码无类)时合并 body 直接走
/// 传输词表(绕过三位码扫描器,body 的 os error 码/时间戳会被误当 HTTP 码);HTTP 码/
/// timeout/connect 显式类保持只喂 status,body 不反转既有判定(401 带 reset 字样仍 Fatal)。
fn classify_status_with_body(status_or_class: &str, body: &str) -> bool { fn classify_status_with_body(status_or_class: &str, body: &str) -> bool {
if is_quota_exhaustion(&format!("{status_or_class} {body}")) { if is_quota_exhaustion(&format!("{status_or_class} {body}")) {
return false; return false;
} }
if status_or_class == "unknown" {
return transport_keyword_hit(&format!("{status_or_class} {body}").to_lowercase());
}
classify_status_or_class(status_or_class) classify_status_or_class(status_or_class)
} }
@@ -834,6 +854,40 @@ mod tests {
assert!(classify_status_with_body("HTTP 500", "Internal Server Error")); assert!(classify_status_with_body("HTTP 500", "Internal Server Error"));
} }
// ---- classify_status_with_body:unknown 类合并 body 喂入,激活传输关键词分支 ----
/// unknown 类 + body 含传输关键词 → 可重试(原只喂 status 恒 Fatal 的 dead code 根治:
/// reset/decode/eof/broken pipe 只出现在 body 原文,status_or_class 永远不带)。
#[test]
fn classify_unknown_with_transport_body_retryable() {
assert!(classify_status_with_body("unknown", "Connection reset by peer (os error 10054)"));
assert!(classify_status_with_body("unknown", "error decoding response body"));
assert!(classify_status_with_body("unknown", "stream ended unexpectedly: unexpected eof"));
assert!(classify_status_with_body("unknown", "failed to send request: broken pipe"));
}
/// unknown 类 + body 无传输关键词 → 仍 Fatal(不回退到无脑可重试空耗)。
#[test]
fn classify_unknown_without_transport_keywords_fatal() {
assert!(!classify_status_with_body("unknown", "服务开小差了"));
assert!(!classify_status_with_body("unknown", ""));
}
/// 显式 HTTP 4xx 即便 body 带传输关键词也保持 Fatal(body 不反转状态码判定)。
#[test]
fn classify_4xx_transport_body_still_fatal() {
assert!(!classify_status_with_body("HTTP 401", "Connection reset by peer"));
assert!(!classify_status_with_body("HTTP 403", "error decoding response body"));
assert!(!classify_status_with_body("HTTP 404", "broken pipe"));
}
/// timeout/connect 显式类不受 body 影响(timeout 串碰含 404 字样的 body 仍可重试)。
#[test]
fn classify_explicit_class_unaffected_by_body() {
assert!(classify_status_with_body("timeout", "HTTP 404 not found"));
assert!(classify_status_with_body("connect", "connection refused"));
}
/// 普通瞬时限流 429 的标准 type=rate_limit_error 不应误判额度耗尽(仍可重试,防误伤) /// 普通瞬时限流 429 的标准 type=rate_limit_error 不应误判额度耗尽(仍可重试,防误伤)
#[test] #[test]
fn standard_rate_limit_error_not_quota() { fn standard_rate_limit_error_not_quota() {