//! complete() 指数退避重试 — 仅非流式同步路径,不动 stream() //! //! 背景: complete() 是 LLM 一次性请求,网络波动/瞬态 5xx/429 会让整轮对话静默失败。 //! 各调用方(title.rs/knowledge_inject.rs/project.rs/ai_node.rs)虽已有人工兜底降级, //! 但重试能在不侵入上层的前提下吸收瞬态错误,对低频后台调用零风险。 //! //! 策略: //! - 最多 3 次尝试(初次 + 2 次重试),指数退避 1s → 2s → 4s + ±50% jitter //! - 总挂钟预算 30s(含 sleep + 各次请求耗时),超预算直接放弃重试返回最后错误 //! - 仅对可重试错误重试: timeout / connect / 5xx / 429; 不重试 4xx(非429) / 参数错 / 鉴权错 //! - 不引入 reqwest-retry / tower: complete 路径简单,手写 loop 零依赖、可调试、不与 SSE/双层 Semaphore 耦合 //! //! 流式 stream() 路径不进入此模块(流式长生成不能整体 timeout,重试会丢增量)。 //! //! 重试期间不释放 Semaphore permit(已在调用方持有),对并发池有挤占 —— 但 complete 调用低频可接受。 use std::future::Future; use std::time::Duration; use rand::Rng; use tracing::warn; /// 最多尝试次数(含初次)。3 次 = 初次 + 2 次重试。 /// /// 配置化 TODO: 未来接入 per-provider config(`AiProviderRecord.config` JSON)或全局开关 /// (`useSetting('df-ai-max-retries')`)时改为读取配置。当前低频后台调用,常量足够。 pub const MAX_COMPLETE_ATTEMPTS: u32 = 3; /// 退避基准(秒): 第 n 次重试前 sleep `BASE_BACKOFF_SECS * 2^(n-1)`。 /// 即 1s → 2s → 4s。 const BASE_BACKOFF_SECS: u64 = 1; /// 整体重试挂钟预算(含 sleep 与各次请求耗时)。超过即放弃,返回最后错误。 /// 与单请求 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; /// 一次尝试的分类结果 —— 在 anyhow 不透明化前决定是否值得重试。 /// /// 暴露给 agentic.rs 流前重试复用(Init 失败分类 Fatal/Retryable 决定是否重试)。 #[derive(Debug)] pub enum AttemptOutcome { /// 成功,携带结果。 Ok(T), /// 可重试错误(timeout / connect / 5xx / 429)。携带人类可读错误描述。 Retryable(String), /// 不可重试错误(4xx 非 429 / 鉴权 / 参数错 / 解析错)。携带描述,直接放弃。 Fatal(String), } /// 判定 reqwest::Error 是否可重试(仅 timeout / connect 类)。 /// /// body 解析错(reqwest::Error::is_decode)与请求构造错(is_builder)属 Fatal,不重试。 pub fn is_reqwest_error_retryable(e: &reqwest::Error) -> bool { e.is_timeout() || e.is_connect() || (e.is_request() && !e.is_builder()) } /// 判定 HTTP 状态码是否可重试: 5xx 与 429 重试,其余(含 4xx)不重试。 /// /// 暴露给 agentic.rs 流前重试复用(4xx Fatal 立即放弃,5xx/429 重试)。 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_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 * factor).clamp(0.0, MAX_BACKOFF_MS as f64) as u64; Duration::from_millis(ms) } /// 对一个返回 `AttemptOutcome` 的异步操作做指数退避重试。 /// /// `attempt_fn` 每次被调用执行一次尝试并返回分类结果。 /// 达到 `MAX_COMPLETE_ATTEMPTS` 或超出 `MAX_TOTAL_BUDGET` 或遇 Fatal 即终止。 /// 终止时若仍失败,返回最后一次的 Retryable/Fatal 描述作为 `anyhow::Error`。 /// /// 调用方约定: `attempt_fn` 内部应保留完整请求重发能力(每次重建 RequestBuilder), /// 因为 reqwest::RequestBuilder 一次 `.send()` 后不可复用。 /// /// 仍 pub(crate)(complete() 内部用),流前重试 stream_recv/agentic 不走此函数 /// (流式 request 不可整体 retry_with_backoff 包裹,需在 agentic loop 内手写循环复用 /// backoff_delay + is_status_retryable)。 pub(crate) async fn retry_with_backoff( label: &str, attempt_fn: F, ) -> anyhow::Result where F: Fn(u32) -> Fut, Fut: Future>, { let deadline = tokio::time::Instant::now() + MAX_TOTAL_BUDGET; let mut last_err = String::from("未尝试"); for attempt in 1..=MAX_COMPLETE_ATTEMPTS { match attempt_fn(attempt).await { AttemptOutcome::Ok(v) => return Ok(v), AttemptOutcome::Fatal(msg) => { // 不可重试: 立即放弃,不浪费预算 return Err(anyhow::anyhow!("{}", msg)); } AttemptOutcome::Retryable(msg) => { last_err = msg; let is_last = attempt == MAX_COMPLETE_ATTEMPTS; if is_last { warn!( label = label, attempt = attempt, "重试已达上限({}次),放弃", MAX_COMPLETE_ATTEMPTS ); break; } let now = tokio::time::Instant::now(); if now >= deadline { warn!( label = label, attempt = attempt, "重试预算({:?})已耗尽,放弃", MAX_TOTAL_BUDGET ); break; } let delay = backoff_delay(attempt).min(deadline.saturating_duration_since(now)); warn!( label = label, attempt = attempt, delay_ms = delay.as_millis() as u64, err = %last_err, "可重试错误,退避后重试" ); tokio::time::sleep(delay).await; } } } Err(anyhow::anyhow!("{} 重试耗尽: {}", label, last_err)) } // ============================================================ // 单测(纯逻辑,不发 HTTP) // ============================================================ #[cfg(test)] mod tests { use super::*; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Arc; #[test] fn backoff_is_monotonic_within_jitter() { // 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_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", attempt, d, lo, hi ); } } /// 倍率溢出安全: 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 = [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)); assert!(is_status_retryable(500)); assert!(is_status_retryable(503)); assert!(!is_status_retryable(400)); assert!(!is_status_retryable(401)); assert!(!is_status_retryable(403)); assert!(!is_status_retryable(404)); } /// 初次成功 → 不重试 #[tokio::test] async fn first_attempt_ok() { let calls = Arc::new(AtomicU32::new(0)); let calls_clone = calls.clone(); let r = retry_with_backoff("test", move |_| { let c = calls_clone.clone(); async move { c.fetch_add(1, Ordering::SeqCst); AttemptOutcome::Ok(42u32) } }) .await .unwrap(); assert_eq!(r, 42); assert_eq!(calls.load(Ordering::SeqCst), 1); } /// 第二次成功 → 恰好 2 次调用 #[tokio::test] async fn retry_once_then_ok() { let calls = Arc::new(AtomicU32::new(0)); let calls_clone = calls.clone(); let r = retry_with_backoff("test", move |_| { let c = calls_clone.clone(); async move { let n = c.fetch_add(1, Ordering::SeqCst) + 1; if n == 1 { AttemptOutcome::Retryable("timeout".into()) } else { AttemptOutcome::Ok(7u32) } } }) .await .unwrap(); assert_eq!(r, 7); assert_eq!(calls.load(Ordering::SeqCst), 2); } /// 全部 Retryable → 用尽 3 次,返回错误 #[tokio::test] async fn all_retryable_exhausts() { let calls = Arc::new(AtomicU32::new(0)); let calls_clone = calls.clone(); let r = retry_with_backoff("test", move |_| { let c = calls_clone.clone(); async move { c.fetch_add(1, Ordering::SeqCst); AttemptOutcome::::Retryable("503".into()) } }) .await; assert!(r.is_err()); assert!(r.unwrap_err().to_string().contains("重试耗尽")); assert_eq!(calls.load(Ordering::SeqCst), MAX_COMPLETE_ATTEMPTS); } /// Fatal 立即放弃,不重试 #[tokio::test] async fn fatal_aborts_immediately() { let calls = Arc::new(AtomicU32::new(0)); let calls_clone = calls.clone(); let r = retry_with_backoff("test", move |_| { let c = calls_clone.clone(); async move { c.fetch_add(1, Ordering::SeqCst); AttemptOutcome::::Fatal("401".into()) } }) .await; assert!(r.is_err()); assert!(r.unwrap_err().to_string().contains("401")); assert_eq!(calls.load(Ordering::SeqCst), 1, "Fatal 不应触发重试"); } }