修复: 协议层与体验缺陷六件

- temperature 按模型适配,仅收 1 的模型不再 400
- 流式 error 帧不再被 choices 必填整帧丢弃
- 只读缓存写后失效
- git diff 中文截断撞 UTF-8 边界 panic
- 非视觉模型纯图片直接拒绝
- 全局快捷键补输入焦点守卫
This commit is contained in:
lxy
2026-10-04 16:27:31 +08:00
parent 01d231c39d
commit 7aa0da32f2
28 changed files with 491 additions and 52 deletions
+95 -7
View File
@@ -5,7 +5,7 @@
//! 但重试能在不侵入上层的前提下吸收瞬态错误,对低频后台调用零风险。
//!
//! 策略:
//! - 最多 3 次尝试(初次 + 2 次重试),指数退避 1s → 2s → 4s + ±20% jitter
//! - 最多 3 次尝试(初次 + 2 次重试),指数退避 1s → 2s → 4s + ±50% jitter
//! - 总挂钟预算 30s(含 sleep + 各次请求耗时),超预算直接放弃重试返回最后错误
//! - 仅对可重试错误重试: timeout / connect / 5xx / 429; 不重试 4xx(非429) / 参数错 / 鉴权错
//! - 不引入 reqwest-retry / tower: complete 路径简单,手写 loop 零依赖、可调试、不与 SSE/双层 Semaphore 耦合
@@ -34,6 +34,11 @@ const BASE_BACKOFF_SECS: u64 = 1;
/// 与单请求 60s timeout 配合: 最坏 3 次 × 60s = 180s,30s 预算会在此之前主动止损。
const MAX_TOTAL_BUDGET: Duration = Duration::from_secs(30);
/// 单次退避基准上限(毫秒),与 [`MAX_TOTAL_BUDGET`] 同口径: 超预算的退避没有意义,
/// attempt 走深时不再指数膨胀。与总预算的一致性由测试 `backoff_cap_matches_budget` 守住。
/// (同构先例: df-tunnel 的 backoff_delay 也以 RECONNECT_MAX 封顶。)
const MAX_BACKOFF_MS: u64 = 30_000;
/// jitter 上限(相对 base 的 ±比例)。避免重试风暴对齐。
/// CR-XX: 范围扩到 ±50%(由 gen_range(-0.5..0.5) × JITTER_RATIO=1.0 合成)。
const JITTER_RATIO: f64 = 1.0;
@@ -65,20 +70,45 @@ pub fn is_status_retryable(status: u16) -> bool {
status == 429 || (500..600).contains(&status)
}
/// 退避倍率 `2^(attempt-1)`,位移溢出安全。
///
/// 替代裸 `1u64 << (attempt - 1)`: attempt > 64 时位移越界(debug panic / release 环绕)、
/// attempt = 0 时下溢 panic。现 attempt = 0 视作 1(倍率 1),attempt - 1 ≥ 64 时
/// checked_shl 返回 None,倍率封顶 u64::MAX,最终时长由 [`MAX_BACKOFF_MS`] 兜底。
/// 全输入无 panic,序列单调不减。
fn backoff_multiplier(attempt: u32) -> u64 {
match 1u64.checked_shl(attempt.saturating_sub(1)) {
Some(m) => m,
None => u64::MAX,
}
}
/// 退避基准毫秒(未加 jitter),`BASE_BACKOFF_SECS * 2^(attempt-1) * 1000`,封顶 [`MAX_BACKOFF_MS`]。
///
/// 乘法全程 saturating: 倍率封顶 u64::MAX 后 `* 1000` 也会二次溢出,不能裸乘。
/// 单一事实源——backoff_delay 与单测的 base 断言都走这里,不再各写一份公式。
fn base_backoff_ms(attempt: u32) -> u64 {
BASE_BACKOFF_SECS
.saturating_mul(backoff_multiplier(attempt))
.saturating_mul(1000)
.min(MAX_BACKOFF_MS)
}
/// 指数退避 + jitter: 返回第 `attempt`(1-based)次重试前应 sleep 的时长。
/// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s,各 ±50% jitter。
///
/// jitter 用 `rand::thread_rng().gen_range(-0.5..0.5)` 生成 ±50% 比例,避免多客户端同步重试风暴。
/// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。
/// 全输入安全: attempt 任意 u32(含 0 / >64)不 panic,结果 ≤ [`MAX_BACKOFF_MS`]。
///
/// 暴露 pub 供 src-tauri/agentic.rs 流前重试复用
/// ("复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。
pub fn backoff_delay(attempt: u32) -> Duration {
let base_ms = BASE_BACKOFF_SECS.saturating_mul(1u64 << (attempt - 1)) * 1000;
let base_ms = base_backoff_ms(attempt) as f64;
// ±50% jitter,相对 base 时长的浮动比例
let jitter_ratio = rand::thread_rng().gen_range(-0.5..0.5); // [-0.5, 0.5)
let factor = 1.0 + jitter_ratio * JITTER_RATIO;
let ms = (base_ms as f64 * factor).max(0.0) as u64;
let ms = (base_ms * factor).clamp(0.0, MAX_BACKOFF_MS as f64) as u64;
Duration::from_millis(ms)
}
@@ -160,12 +190,12 @@ mod tests {
#[test]
fn backoff_is_monotonic_within_jitter() {
// base 序列: 1, 2, 4 秒; jitter ±20% 后仍在 [0.8,4.8] 范围
// base 序列: 1, 2, 4 秒; jitter ±50%(JITTER_RATIO=1.0) 后仍在 [0.5x, 1.5x] 范围
for attempt in 1..=3 {
let d = backoff_delay(attempt);
let base_ms = (BASE_BACKOFF_SECS * (1u64 << (attempt - 1))) * 1000;
let lo = (base_ms as f64 * (1.0 - JITTER_RATIO)) as u64;
let hi = (base_ms as f64 * (1.0 + JITTER_RATIO)) as u64;
let base_ms = base_backoff_ms(attempt);
let lo = (base_ms as f64 * (1.0 - JITTER_RATIO / 2.0)) as u64;
let hi = (base_ms as f64 * (1.0 + JITTER_RATIO / 2.0)) as u64;
assert!(
d.as_millis() as u64 >= lo && d.as_millis() as u64 <= hi,
"attempt={} delay={:?} 应在 [{},{}]ms",
@@ -177,6 +207,64 @@ mod tests {
}
}
/// 倍率溢出安全: attempt=0 不下溢,≥65 封顶 u64::MAX,序列单调不减
#[test]
fn backoff_multiplier_extremes_safe() {
assert_eq!(backoff_multiplier(0), 1, "attempt=0 视作 1,不下溢 panic");
assert_eq!(backoff_multiplier(1), 1);
assert_eq!(backoff_multiplier(2), 2);
assert_eq!(backoff_multiplier(3), 4);
assert_eq!(backoff_multiplier(63), 1u64 << 62);
assert_eq!(backoff_multiplier(64), 1u64 << 63, "u64 可表达的最大位移");
assert_eq!(backoff_multiplier(65), u64::MAX, "checked_shl 越界封顶");
assert_eq!(backoff_multiplier(100), u64::MAX);
assert_eq!(backoff_multiplier(u32::MAX), u64::MAX);
let seq: Vec<u64> = [0u32, 1, 2, 3, 10, 63, 64, 65, 100, u32::MAX]
.iter()
.map(|&a| backoff_multiplier(a))
.collect();
assert!(seq.windows(2).all(|w| w[0] <= w[1]), "倍率应单调不减: {:?}", seq);
}
/// 基准毫秒单调不减、封顶值正确(2^5×1s=32s 已超 30s 预算,从 attempt=6 起封顶)
#[test]
fn base_backoff_ms_monotonic_and_capped() {
let mut prev = 0u64;
for attempt in [0u32, 1, 2, 3, 4, 5, 6, 10, 63, 64, 65, 100, u32::MAX] {
let b = base_backoff_ms(attempt);
assert!(b >= prev, "attempt={} base_ms={} 应 ≥ 前值 {}", attempt, b, prev);
assert!(b <= MAX_BACKOFF_MS, "attempt={} base_ms={} 应 ≤ {}", attempt, b, MAX_BACKOFF_MS);
prev = b;
}
assert_eq!(base_backoff_ms(1), 1000);
assert_eq!(base_backoff_ms(2), 2000);
assert_eq!(base_backoff_ms(3), 4000);
assert_eq!(base_backoff_ms(5), 16_000, "未触及封顶");
assert_eq!(base_backoff_ms(6), MAX_BACKOFF_MS, "32s 超预算封顶 30s");
assert_eq!(base_backoff_ms(u32::MAX), MAX_BACKOFF_MS);
}
/// 极端 attempt 下 backoff_delay 不 panic(修复: 1u64<<(attempt-1) 曾 debug panic/release 环绕)
#[test]
fn backoff_delay_extreme_attempts_no_panic_and_capped() {
for attempt in [0u32, 1, 10, 63, 64, 65, 100, u32::MAX] {
let ms = backoff_delay(attempt).as_millis() as u64;
assert!(
ms <= MAX_BACKOFF_MS,
"attempt={} 实际 {}ms 应 ≤ {}ms(含 jitter 后仍不超)",
attempt,
ms,
MAX_BACKOFF_MS
);
}
}
/// 封顶常量与总预算同口径(改预算时两处同步)
#[test]
fn backoff_cap_matches_budget() {
assert_eq!(MAX_BACKOFF_MS, MAX_TOTAL_BUDGET.as_millis() as u64);
}
#[test]
fn status_classification() {
assert!(is_status_retryable(429));