Skip to content

[bug][billing] polish send_with_transient_retry 会重试「请求体写出超时」→ 可能重复 LLM 调用与计费 #680

@appergb

Description

@appergb

现象

润色(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 专项)。

Metadata

Metadata

Assignees

Labels

P2Medium prioritybugSomething isn't working

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions