fix: ASR 切换强制覆盖 endpoint/model + 重试只限连接层失败

处理 PR-Agent 第 8 轮:
- ProvidersSection.onAsrProviderChange: asr.endpoint / asr.model 是所有 ASR 厂商
  共用的凭据槽,原「仅槽空时填」逻辑使跨厂商切换不生效(dropdown 切了、实际仍
  打旧厂商地址)。改为强制覆盖预设默认值,与 onLlmProviderChange 一致。
- net.rs send_with_retry: 重试范围从 is_connect() || is_request() 收窄到只剩
  is_connect()。is_request() 多为确定性失败(endpoint 配置错误等),重试 10 次
  只是徒增数秒延迟才暴露错误;连接层失败才是真正瞬时、值得重试的一类。
This commit is contained in:
baiqing committed 2026-05-22 23:05:18 +08:00
1 parent 00b2bd0a8d
commit 99f22a6aee
2 files changed
+19 -23

No files matched your search

+12 -10
View File
@@ -7,11 +7,12 @@
//! 这里提供两件东西:
//! - `http()`:进程级共享客户端。一次握手成功后的连接进连接池,后续命令直接复用,
//! 不再付握手成本。
//! - `send_with_retry`:对「连接失败 / 请求未送出」这两类传输层失败做指数退避
//! 重试 —— 它们都发生在请求送达服务端之前,重试幂等安全。**不重试超时**:
//! reqwest 的 `is_timeout()` 也涵盖「请求已发出、等响应时超时」,此时服务端可能
//! 已收到并在处理,重试 POST / DELETE 会重复执行。HTTP 4xx/5xx 同样不重试 ——
//! 服务端已应答,状态码交给调用方判断。
//! - `send_with_retry`:只对**连接层失败**(`is_connect()` —— 握手重置 / 连接被拒
//! 等)做指数退避重试。这类失败发生在请求送达服务端之前、且通常是瞬时的(代理
//! 分流抖动等),重试既幂等安全又有意义。**不重试超时与其他请求层错误**:超时
//! 可能发生在服务端已收到之后(重试 POST / DELETE 会重复执行);`is_request()`
//! 类错误多为确定性失败(如 endpoint 配置错误),重试只是徒增数秒延迟。HTTP
//! 4xx/5xx 同样不重试 —— 服务端已应答,状态码交给调用方判断。
use std::time::Duration;
@@ -38,12 +39,13 @@ pub fn http() -> &'static reqwest::Client {
/// 单次请求最多尝试的次数。失败本身很快(握手重置 ~0.5s),10 次总耗时仍可控。
const MAX_ATTEMPTS: u32 = 10;
/// 发送请求,对连接层失败(连接重置 / 请求未送出)做指数退避重试。
/// 发送请求,只对连接层失败(`is_connect()`:握手重置 / 连接被拒等)做指数退避重试。
///
/// `make` 每次尝试都重新构造 `RequestBuilder`(`send()` 会消耗它)。只重试
/// `is_connect()` / `is_request()` —— 这两类失败时请求尚未送达服务端,重试幂等
/// 安全。**超时不重试**:服务端可能已收到并在处理,重试非幂等请求(POST / DELETE)
/// 会重复执行。拿到任意 HTTP 响应(含 4xx/5xx)即返回,状态码由调用方自行判断。
/// `is_connect()` —— 连接尚未建立、请求未送达服务端,且这类失败通常是瞬时的,
/// 重试幂等安全且有价值。超时(可能服务端已在处理)与其他 `is_request()` 类错误
/// (多为 endpoint 配置错误等确定性失败)都不重试。拿到任意 HTTP 响应(含
/// 4xx/5xx)即返回,状态码由调用方自行判断。
pub async fn send_with_retry<F>(make: F) -> reqwest::Result<reqwest::Response>
where
F: Fn() -> reqwest::RequestBuilder,
@@ -54,7 +56,7 @@ where
match make().send().await {
Ok(resp) => return Ok(resp),
Err(err) => {
let retryable = err.is_connect() || err.is_request();
let retryable = err.is_connect();
if !retryable || attempt >= MAX_ATTEMPTS {
return Err(err);
}
@@ -270,25 +270,19 @@ export function ProvidersSection() {
await updatePrefs(next);
if (seq !== asrSwitchSeqRef.current) return;
}
// OpenAI 兼容厂商首次切换时预填 baseUrl / model 默认值,省得用户必踩
// 「跨厂商 model 名根本不一样」的坑;但用户已自定义后就不再覆盖。
// volcengine 走另一套凭据,跳过。
// asr.endpoint / asr.model 是所有 ASR 厂商共用的一对凭据槽(persistence.rs
// 未做 per-provider 隔离)。若只在槽空时填默认值,老用户从 A 厂商切到 B 厂商
// 时槽里仍是 A 的 endpoint/model —— dropdown 切了、实际还打 A 的地址。改成切到
// 有默认值的预设就强制覆盖,让切换真切到位。volcengine 走另一套凭据、本地引擎
// 无 baseUrl,都被 if 守卫天然跳过。与 onLlmProviderChange 同款修法。
const preset = ASR_PRESETS.find(p => p.id === id);
if (preset && preset.baseUrl) {
const existing = await readCredential('asr.endpoint');
await setCredential('asr.endpoint', preset.baseUrl);
if (seq !== asrSwitchSeqRef.current) return;
if (!existing) {
await setCredential('asr.endpoint', preset.baseUrl);
if (seq !== asrSwitchSeqRef.current) return;
}
}
if (preset && preset.model) {
const existing = await readCredential('asr.model');
await setCredential('asr.model', preset.model);
if (seq !== asrSwitchSeqRef.current) return;
if (!existing) {
await setCredential('asr.model', preset.model);
if (seq !== asrSwitchSeqRef.current) return;
}
}
setCommittedAsrProvider(id);
emitSaved('saved', t('common.saved'));