From 814dcf2a228823aa0880cc943347fe47073e26f0 Mon Sep 17 00:00:00 2001 From: shadyrispy <144605632+shadyrispy@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:08:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(upstream)=EF=BC=9A404=20=E7=B1=BB=E7=A1=AE?= =?UTF-8?q?=E5=AE=9A=E6=80=A7=E5=A4=B1=E8=B4=A5=E4=B8=8D=E5=86=8D=E5=8E=9F?= =?UTF-8?q?=E5=9C=B0=E9=87=8D=E5=8F=91=EF=BC=8C=E7=9C=81=E4=B8=8B=E6=8D=A2?= =?UTF-8?q?=E5=8F=B7=E5=89=8D=E7=9A=84=E7=99=BD=E7=AD=89=EF=BC=88issue=20#?= =?UTF-8?q?255=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit issue #255 的面板截图:OfficeAce 对目录里有、套餐里没有的名字回 `404 {"code":null,"message":"Invalid model."}`,fallback_retry_advice 把它当普通 Fatal,按「同一账号重试次数」原地重发 3 次 × 5 秒才换号——客户端白等 15 秒, 换来的还是同一个 404(截图里 AutoClaw 第二账号一次就成了)。 404 与 #241 的 413 同一类:结论只由「这份 body 长什么样」决定,同一账号重发 同一份 body 必然得到同一个失败,变量是账号不是字节。所以把 `DETERMINISTIC_NOT_FOUND_STATUSES = [404]` 加进 fallback_retry_advice 的豁免 (与 PAYLOAD_SIZE_REJECTION_STATUSES 分开两个常量:一个是字节、一个是名字, 出处不同,将来排除面也可能各自变)。豁免后直接进账号轮换——别的账号可能有 这个模型(截图里第二发就成 200)。 413 那条豁免的完整论证(代价不对称:每次往返 13 秒 + 5 秒间隔,客户端 60 秒墙先到) 见 fallback_retry_advice 的函数头,404 同样适用。普通 400 不在豁免面(语义由上游定, 重发是安全默认),测试里有对照断言。 --- .../src/server/core/upstream/provider_loop.rs | 18 ++++++++++- .../core/upstream/provider_loop/tests.rs | 31 +++++++++++++++++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/desktop-tauri/src-tauri/server/src/server/core/upstream/provider_loop.rs b/desktop-tauri/src-tauri/server/src/server/core/upstream/provider_loop.rs index 09a884be..49b68d0a 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/upstream/provider_loop.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/upstream/provider_loop.rs @@ -257,6 +257,19 @@ const TRANSIENT_RETRY_STATUSES: &[u16] = &[408, 500, 502, 503, 504]; /// 再看一眼有意义;413 没有任何会变的量。 const PAYLOAD_SIZE_REJECTION_STATUSES: &[u16] = &[413]; +/// 按**请求的路由 / 模型名**拒收的状态码:上游在解析请求时就没找到目标 +/// (模型不在这一家的套餐里、路由不存在),结论只由「这份 body 长什么样」决定, +/// 同一账号重发同一份 body 必然得到同一个 404 ⇒ 不进原地重发, +/// 换账号才是有意义的动作(别的账号可能有这个模型)。 +/// +/// 实测形状(issue #255):OfficeAce 对目录里有、套餐里没有的名字回 +/// `404 {"code":null,"message":"Invalid model."}`,旧判据把它当普通 Fatal, +/// 原地重发 3 次 × 5 秒才换号——客户端白等 15 秒,换来的还是同一个 404。 +/// +/// 与 [`PAYLOAD_SIZE_REJECTION_STATUSES`] 分成两个常量而不是合一: +/// 一个是「字节」,一个是「名字」,出处与措辞不同,将来各自的排除面也可能变。 +const DETERMINISTIC_NOT_FOUND_STATUSES: &[u16] = &[404]; + /// 这次的失败状态码是否命中「指定错误码直接换号」名单(设置页「通用 → 请求重试」)。 /// /// 命中的失败**跳过本账号**:不进 [`send_with_retry`] 的原地重发,也不做同 @@ -345,7 +358,10 @@ fn fallback_retry_advice( | UpstreamErrorClass::ContentBlocked { .. } => None, // 体积被入口拒收:重发同一份字节只会得到同一个 413(换号也一样,但那是 // 编排层的事,这里只撤「原地再发一次」这一件事) - UpstreamErrorClass::Fatal { .. } if PAYLOAD_SIZE_REJECTION_STATUSES.contains(&status) => { + UpstreamErrorClass::Fatal { .. } + if PAYLOAD_SIZE_REJECTION_STATUSES.contains(&status) + || DETERMINISTIC_NOT_FOUND_STATUSES.contains(&status) => + { None } UpstreamErrorClass::Fatal { .. } => Some(RetryAdvice { diff --git a/desktop-tauri/src-tauri/server/src/server/core/upstream/provider_loop/tests.rs b/desktop-tauri/src-tauri/server/src/server/core/upstream/provider_loop/tests.rs index deb16802..67968a7c 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/upstream/provider_loop/tests.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/upstream/provider_loop/tests.rs @@ -438,10 +438,11 @@ fn a_payload_size_rejection_gets_no_in_place_retry() { } /// 反对照:其它 4xx/未知失败照旧走兜底原地重发(这条改动只放开"体积拒收"那一格, -/// 没有把 400 这类"含义由上游决定"的码一并排除)。 +/// 没有把 400 这类"含义由上游决定"的码一并排除)。404 已于 issue #255 单独豁免 +/// (路由 / 模型名类确定性失败,见 `DETERMINISTIC_NOT_FOUND_STATUSES`),不在此列。 #[test] fn other_fatal_statuses_still_get_the_fallback_resend() { - for status in [400u16, 404, 409] { + for status in [400u16, 409] { assert!( fallback_retry_advice(&fatal(status), 3, status).is_some(), "HTTP {status} 的语义由上游决定,兜底原地重发的既有行为不该变" @@ -484,3 +485,29 @@ async fn a_real_413_response_is_sent_once_and_handed_back() { "状态码 413 要原样带回,不该被折成别的失败" ); } + +#[test] +fn a_deterministic_404_skips_the_in_place_resend() { + // issue #255 的形状:OfficeAce 对目录里有、套餐里没有的名字回 + // `404 {"code":null,"message":"Invalid model."}`,旧判据把它当普通 Fatal, + // 原地重发 3 次 × 5 秒才换号——客户端白等 15 秒,换来的还是同一个 404。 + let not_found = UpstreamErrorClass::Fatal { + status: 404, + upstream_code: None, + message: "上游返回 404: {\"code\":null,\"message\":\"Invalid model.\"}".to_string(), + }; + assert!( + fallback_retry_advice(¬_found, 3, 404).is_none(), + "名字类确定性失败不该再原地重发" + ); + // 对照:普通 400 仍走原地重发(400 的语义由上游定,重发是安全默认) + let bad_request = UpstreamErrorClass::Fatal { + status: 400, + upstream_code: None, + message: "上游返回 400: whatever".to_string(), + }; + assert!( + fallback_retry_advice(&bad_request, 3, 400).is_some(), + "404 的豁免不许顺手把 400 也豁免了" + ); +}