现象
润色(polish)的 LLM 请求重试逻辑在「请求体写出阶段超时」时可能重发已经发出的非幂等请求,导致重复 LLM completion、重复插入文本、双重计费。
根因(已读源码确认)
src-tauri/src/polish.rs:1264 send_with_transient_retry:
match initial.send().await {
Ok(r) => Ok(r),
Err(e) if e.is_connect() || e.is_request() => { /* retry 一次 */ } // ← 1280
Err(e) => { if e.is_timeout() { Timeout } ... } // ← 1298
}
该函数自己的文档(:1253-1256)明确:「is_timeout() 故意不重试——超时时服务端可能已在处理并扣费」。但匹配臂顺序是先判 is_connect() || is_request()。reqwest 会把请求体写出阶段的超时归类为 is_request()(有时同时 is_request() + is_timeout())→ 这类错误先命中 :1280 重试臂,根本到不了 :1298 的 timeout 分支 → 与文档意图相悖,且与项目共享的 net.rs:68 send_with_retry(只对 is_connect() 重试)矛盾。
影响
非幂等的 chat/completions 在 body 写出期超时可能被重发 → 用户看到重复文本 + 供应商双重计费。
建议修法(单一职责)
把重试臂收紧为不含超时:
Err(e) if (e.is_connect() || e.is_request()) && !e.is_timeout() => { /* retry */ }
或整体改走 net::send_with_retry(仅 is_connect())。
验证
单测:构造 is_request()+is_timeout() 的 mock 错误,断言不触发重试;is_connect() 仍重试一次。
来源:2026-06-16 全仓多 Agent 审计(polish 专项)。
现象
润色(polish)的 LLM 请求重试逻辑在「请求体写出阶段超时」时可能重发已经发出的非幂等请求,导致重复 LLM completion、重复插入文本、双重计费。
根因(已读源码确认)
src-tauri/src/polish.rs:1264 send_with_transient_retry:该函数自己的文档(
:1253-1256)明确:「is_timeout()故意不重试——超时时服务端可能已在处理并扣费」。但匹配臂顺序是先判is_connect() || is_request()。reqwest 会把请求体写出阶段的超时归类为is_request()(有时同时is_request()+is_timeout())→ 这类错误先命中:1280重试臂,根本到不了:1298的 timeout 分支 → 与文档意图相悖,且与项目共享的net.rs:68 send_with_retry(只对is_connect()重试)矛盾。影响
非幂等的
chat/completions在 body 写出期超时可能被重发 → 用户看到重复文本 + 供应商双重计费。建议修法(单一职责)
把重试臂收紧为不含超时:
或整体改走
net::send_with_retry(仅is_connect())。验证
单测:构造
is_request()+is_timeout()的 mock 错误,断言不触发重试;is_connect()仍重试一次。