From f4994d5195ff70d2b1a1fd62a6162497052566bf Mon Sep 17 00:00:00 2001 From: VioletVenti Date: Tue, 23 Jun 2026 18:28:24 +0000 Subject: [PATCH] feat(proxy): configurable per-app transport-retry interval The local proxy retried upstream connection/timeout errors immediately (hardcoded 0s interval between attempts) with no way to configure it. Add a per-app `retry_interval_seconds` field so the wait between transport retries is tunable. - New `retry_interval_seconds` column on `proxy_config` (INTEGER NOT NULL DEFAULT 0) via schema migration v11 -> v12; default 0 preserves the prior immediate-retry behavior (fully backward compatible). Fresh DBs get the column in CREATE TABLE; the migration guards on table existence and is idempotent. - DAO read/write (per-app SELECT x2, UPDATE, defaults) carries the field. - Forwarder sleeps the configured interval between retries at all four transport-retry continue points (streaming/buffered x connect/timeout); None or Duration::ZERO skips the sleep, so the fast path adds no async overhead. Only the pre-first-byte transport-retry layer is affected. - CLI: `cc-switch proxy config --retry-interval-seconds ` (0-300, Claude/Codex/Gemini only), shown per-app in `cc-switch proxy show` under a new "Retry interval" section. - Tests: migration (column + default + data preserved), DAO round-trip, forwarder timing (proves the proxy actually sleeps >= interval on retry, plus a no-interval control), CLI setter persistence across DB reopen. Auth is untouched; mid-stream disconnect retries remain out of scope. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 + src-tauri/src/cli/commands/proxy.rs | 121 ++++++++++++++++-- src-tauri/src/database/dao/proxy.rs | 9 +- src-tauri/src/database/mod.rs | 2 +- src-tauri/src/database/schema.rs | 29 +++++ src-tauri/src/database/tests.rs | 60 +++++++++ src-tauri/src/proxy/forwarder.rs | 19 +++ .../src/proxy/forwarder/tests/error_paths.rs | 104 ++++++++++++++- .../forwarder/tests/provider_failover.rs | 18 +++ .../proxy/forwarder/tests/request_building.rs | 30 +++++ src-tauri/src/proxy/handler_context.rs | 9 ++ src-tauri/src/proxy/handlers.rs | 4 + src-tauri/src/proxy/types.rs | 2 + src-tauri/tests/proxy_database.rs | 23 ++++ src-tauri/tests/proxy_retry_interval.rs | 78 +++++++++++ 15 files changed, 501 insertions(+), 13 deletions(-) create mode 100644 src-tauri/tests/proxy_retry_interval.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5182c596..6f7523b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ All notable changes to CC Switch CLI will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Proxy / Retry**: Add a configurable per-app fixed transport-retry interval. The local proxy previously retried upstream connection/timeout errors immediately (hardcoded 0s interval). The new `retry_interval_seconds` field (stored in `proxy_config`, default `0` = immediate, backward-compatible) makes that wait configurable per app via `cc-switch proxy config --retry-interval-seconds ` (range 0–300, Claude/Codex/Gemini only), and is shown in `cc-switch proxy show` under a new per-app "Retry interval" section. Only the pre-first-byte transport-retry layer is affected; auth is untouched and mid-stream disconnect retries remain out of scope. Backed by schema migration v11 → v12. + ## [5.8.4] - 2026-06-19 ### Added diff --git a/src-tauri/src/cli/commands/proxy.rs b/src-tauri/src/cli/commands/proxy.rs index fac12653..c5736a3f 100644 --- a/src-tauri/src/cli/commands/proxy.rs +++ b/src-tauri/src/cli/commands/proxy.rs @@ -33,6 +33,10 @@ pub enum ProxyCommand { /// Set the selected app's daemon worker listen port #[arg(long)] listen_port: Option, + + /// Set the selected app's fixed transport-retry interval in seconds (0 = immediate) + #[arg(long)] + retry_interval_seconds: Option, }, /// Start the local proxy in the foreground for debugging @@ -60,7 +64,13 @@ pub fn execute(cmd: ProxyCommand, app: Option) -> Result<(), AppError> ProxyCommand::Config { listen_address, listen_port, - } => configure_proxy(app_type, listen_address, listen_port), + retry_interval_seconds, + } => configure_proxy( + app_type, + listen_address, + listen_port, + retry_interval_seconds, + ), ProxyCommand::Serve { listen_address, listen_port, @@ -90,8 +100,31 @@ fn show_proxy() -> Result<(), AppError> { .block_on(state.proxy_service.get_takeover_status()) .map_err(AppError::Message)?; + // 读取各 app 的 per-app 重试间隔(只读、不写入;缺失行返回默认 0)。 + let retry_intervals = [ + (AppType::Claude, "Claude"), + (AppType::Codex, "Codex"), + (AppType::Gemini, "Gemini"), + ] + .into_iter() + .map(|(app, label)| { + let seconds = runtime + .block_on(state.db.get_proxy_config_for_app_or_default(app.as_str())) + .map(|cfg| cfg.retry_interval_seconds) + .unwrap_or(0); + (label, seconds) + }) + .collect::>(); + println!("{}", highlight(crate::t!("Local Proxy", "本地代理"))); - for line in build_proxy_overview_lines(&state, &config, &status, &app_ports, &takeovers) { + for line in build_proxy_overview_lines( + &state, + &config, + &status, + &app_ports, + &takeovers, + &retry_intervals, + ) { println!("{line}"); } @@ -132,12 +165,19 @@ fn set_proxy_enabled(app_type: AppType, enabled: bool) -> Result<(), AppError> { Ok(()) } +/// 重试间隔允许的最大值(秒)。 +/// +/// 重试间隔不计入单次请求的超时预算(每次重试会重新计算 remaining_timeout), +/// 过大的值会让整体请求远超 request_timeout,这里设一个合理上限避免误配。 +const MAX_RETRY_INTERVAL_SECONDS: u32 = 300; + fn configure_proxy( app_type: AppType, listen_address: Option, listen_port: Option, + retry_interval_seconds: Option, ) -> Result<(), AppError> { - if listen_address.is_none() && listen_port.is_none() { + if listen_address.is_none() && listen_port.is_none() && retry_interval_seconds.is_none() { return show_proxy(); } let listen_address = listen_address.map(|address| address.trim().to_string()); @@ -147,14 +187,23 @@ fn configure_proxy( if let Some(port) = listen_port { validate_proxy_listen_port(port)?; } - if listen_port.is_some() - && !matches!(app_type, AppType::Claude | AppType::Codex | AppType::Gemini) - { + // 重试间隔与 listen_port 一样,仅在 Claude/Codex/Gemini 上有意义 + // (这三个 app 走 proxy_config 的 per-app 配置)。 + let needs_proxy_app = listen_port.is_some() || retry_interval_seconds.is_some(); + if needs_proxy_app && !matches!(app_type, AppType::Claude | AppType::Codex | AppType::Gemini) { return Err(AppError::InvalidInput(format!( - "proxy takeover is not supported for {}", + "proxy config is not supported for {}", app_type.as_str() ))); } + if let Some(seconds) = retry_interval_seconds { + if seconds > MAX_RETRY_INTERVAL_SECONDS { + return Err(AppError::InvalidInput(format!( + "retry interval {}s exceeds maximum {}s", + seconds, MAX_RETRY_INTERVAL_SECONDS + ))); + } + } let state = get_state()?; let runtime = create_runtime()?; let status = runtime.block_on(state.proxy_service.get_status()); @@ -202,6 +251,22 @@ fn configure_proxy( )) ); } + + if let Some(seconds) = retry_interval_seconds { + // 读-改-写:proxy_config 没有 per-app 的单字段 setter,走标准更新路径。 + let mut config = runtime.block_on(state.db.get_proxy_config_for_app(app_type.as_str()))?; + config.retry_interval_seconds = seconds; + runtime.block_on(state.db.update_proxy_config_for_app(config))?; + println!( + "{}", + success(&format!( + "{} {}: {}s", + crate::t!("Proxy retry interval", "代理重试间隔"), + app_type.as_str(), + seconds + )) + ); + } Ok(()) } @@ -474,6 +539,7 @@ fn build_proxy_overview_lines( status: &crate::ProxyStatus, app_ports: &[(AppType, u16)], takeovers: &crate::proxy::types::ProxyTakeoverStatus, + retry_intervals: &[(&str, u32)], ) -> Vec { let current_providers = AppType::all() .map(|app| { @@ -566,6 +632,8 @@ fn build_proxy_overview_lines( crate::t!("Auto failover:", "自动故障转移:").to_string(), ]); lines.extend(build_auto_failover_status_lines(state)); + lines.extend([String::new(), build_retry_interval_header()]); + lines.extend(build_retry_interval_lines(retry_intervals)); lines.extend([ String::new(), crate::t!("Current providers:", "当前供应商:").to_string(), @@ -638,6 +706,25 @@ fn build_auto_failover_status_lines(state: &AppState) -> Vec { .collect() } +fn build_retry_interval_header() -> String { + crate::t!("Retry interval:", "重试间隔:").to_string() +} + +/// 渲染各 app 的 per-app 重试间隔。`0` 显示为「立即」(保持旧行为),否则显示秒数。 +fn build_retry_interval_lines(retry_intervals: &[(&str, u32)]) -> Vec { + retry_intervals + .iter() + .map(|(label, seconds)| { + let value = if *seconds == 0 { + crate::t!("immediate", "立即").to_string() + } else { + format!("{seconds}s") + }; + format!("- {label}: {value}") + }) + .collect() +} + #[cfg(test)] mod tests { use std::sync::{Arc, RwLock}; @@ -724,7 +811,15 @@ mod tests { gemini: true, }; - let lines = build_proxy_overview_lines(&state, &config, &status, &app_ports, &takeover); + let retry_intervals = [("Claude", 0u32), ("Codex", 0u32), ("Gemini", 0u32)]; + let lines = build_proxy_overview_lines( + &state, + &config, + &status, + &app_ports, + &takeover, + &retry_intervals, + ); let output = lines.join("\n"); assert!( @@ -792,7 +887,15 @@ mod tests { let takeover = ProxyTakeoverStatus::default(); let app_ports = load_proxy_app_ports(&state).expect("load app proxy ports"); - let lines = build_proxy_overview_lines(&state, &config, &status, &app_ports, &takeover); + let retry_intervals = [("Claude", 0u32), ("Codex", 0u32), ("Gemini", 0u32)]; + let lines = build_proxy_overview_lines( + &state, + &config, + &status, + &app_ports, + &takeover, + &retry_intervals, + ); let output = lines.join("\n"); assert!( diff --git a/src-tauri/src/database/dao/proxy.rs b/src-tauri/src/database/dao/proxy.rs index c9bb9431..29684bf6 100644 --- a/src-tauri/src/database/dao/proxy.rs +++ b/src-tauri/src/database/dao/proxy.rs @@ -35,6 +35,7 @@ fn default_app_proxy_config(app_type: impl Into) -> AppProxyConfig { enabled: false, auto_failover_enabled: false, max_retries: 3, + retry_interval_seconds: 0, streaming_first_byte_timeout: 60, streaming_idle_timeout: 120, non_streaming_timeout: 600, @@ -334,7 +335,7 @@ impl Database { "SELECT app_type, enabled, auto_failover_enabled, max_retries, streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout, circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds, - circuit_error_rate_threshold, circuit_min_requests + circuit_error_rate_threshold, circuit_min_requests, retry_interval_seconds FROM proxy_config WHERE app_type = ?1", [app_type], |row| { @@ -351,6 +352,7 @@ impl Database { circuit_timeout_seconds: row.get::<_, i32>(9)? as u32, circuit_error_rate_threshold: row.get(10)?, circuit_min_requests: row.get::<_, i32>(11)? as u32, + retry_interval_seconds: row.get::<_, i32>(12)? as u32, }) }, ) @@ -380,7 +382,7 @@ impl Database { "SELECT app_type, enabled, auto_failover_enabled, max_retries, streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout, circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds, - circuit_error_rate_threshold, circuit_min_requests + circuit_error_rate_threshold, circuit_min_requests, retry_interval_seconds FROM proxy_config WHERE app_type = ?1", [app_type], |row| { @@ -397,6 +399,7 @@ impl Database { circuit_timeout_seconds: row.get::<_, i32>(9)? as u32, circuit_error_rate_threshold: row.get(10)?, circuit_min_requests: row.get::<_, i32>(11)? as u32, + retry_interval_seconds: row.get::<_, i32>(12)? as u32, }) }, ) @@ -437,6 +440,7 @@ impl Database { circuit_timeout_seconds = ?10, circuit_error_rate_threshold = ?11, circuit_min_requests = ?12, + retry_interval_seconds = ?13, updated_at = datetime('now') WHERE app_type = ?1", rusqlite::params![ @@ -452,6 +456,7 @@ impl Database { config.circuit_timeout_seconds as i32, config.circuit_error_rate_threshold, config.circuit_min_requests as i32, + config.retry_interval_seconds as i32, ], ) .map_err(|e| AppError::Database(e.to_string()))?; diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index dfa4cde5..fc7420da 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -59,7 +59,7 @@ static DATABASE_PERMISSION_CHECK: Once = Once::new(); /// 当前 Schema 版本号 /// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑 -pub(crate) const SCHEMA_VERSION: i32 = 11; +pub(crate) const SCHEMA_VERSION: i32 = 12; fn database_open_flags() -> OpenFlags { OpenFlags::SQLITE_OPEN_READ_WRITE diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 62b8be29..9fa8a0bc 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -124,6 +124,7 @@ impl Database { circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2, circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6, circuit_min_requests INTEGER NOT NULL DEFAULT 10, + retry_interval_seconds INTEGER NOT NULL DEFAULT 0, default_cost_multiplier TEXT NOT NULL DEFAULT '1', pricing_model_source TEXT NOT NULL DEFAULT 'response', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) @@ -427,6 +428,13 @@ impl Database { Self::migrate_v10_to_v11(conn)?; Self::set_user_version(conn, 11)?; } + 11 => { + log::info!( + "迁移数据库从 v11 到 v12(proxy_config 增加固定重试间隔 retry_interval_seconds)" + ); + Self::migrate_v11_to_v12(conn)?; + Self::set_user_version(conn, 12)?; + } _ => { return Err(AppError::Database(format!( "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" @@ -757,6 +765,7 @@ impl Database { circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2, circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6, circuit_min_requests INTEGER NOT NULL DEFAULT 10, + retry_interval_seconds INTEGER NOT NULL DEFAULT 0, default_cost_multiplier TEXT NOT NULL DEFAULT '1', pricing_model_source TEXT NOT NULL DEFAULT 'response', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) @@ -1347,6 +1356,26 @@ impl Database { Ok(()) } + /// v11 -> v12 迁移:为 proxy_config 增加固定重试间隔列 + /// + /// `retry_interval_seconds` 默认 0(立即重试),保持升级前的旧行为。 + /// 新建库的 CREATE TABLE 已包含该列,此处对老库用幂等 ALTER 补齐。 + /// 部分迁移测试会用不含 proxy_config 的最小 schema 驱动完整迁移链,因此先确认表存在。 + fn migrate_v11_to_v12(conn: &Connection) -> Result<(), AppError> { + if Self::table_exists(conn, "proxy_config")? { + Self::add_column_if_missing( + conn, + "proxy_config", + "retry_interval_seconds", + "INTEGER NOT NULL DEFAULT 0", + )?; + log::info!("v11 -> v12 迁移完成:proxy_config 已增加 retry_interval_seconds 列"); + } else { + log::info!("v11 -> v12:proxy_config 不存在,跳过 retry_interval_seconds 列"); + } + Ok(()) + } + /// 插入默认模型定价数据 /// 格式: (model_id, display_name, input, output, cache_read, cache_creation) /// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致 diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index 5c510d71..e6a75e87 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -502,6 +502,66 @@ fn schema_migration_v4_adds_pricing_model_columns() { ); } +#[test] +fn schema_migration_v11_adds_retry_interval_seconds() { + let conn = Connection::open_in_memory().expect("open memory db"); + // 模拟一个 v11 数据库:proxy_config 已是 per-app 结构,但尚无 retry_interval_seconds 列。 + conn.execute_batch( + r#" + CREATE TABLE proxy_config ( + app_type TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 0, + auto_failover_enabled INTEGER NOT NULL DEFAULT 0, + max_retries INTEGER NOT NULL DEFAULT 3, + streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60, + streaming_idle_timeout INTEGER NOT NULL DEFAULT 120, + non_streaming_timeout INTEGER NOT NULL DEFAULT 600, + circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, + circuit_success_threshold INTEGER NOT NULL DEFAULT 2, + circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, + circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6, + circuit_min_requests INTEGER NOT NULL DEFAULT 10 + ); + INSERT INTO proxy_config (app_type, max_retries) VALUES ('codex', 3); + "#, + ) + .expect("seed v11 schema"); + + assert!( + !Database::has_column(&conn, "proxy_config", "retry_interval_seconds") + .expect("check column before migration"), + "column should not exist before migration" + ); + + Database::set_user_version(&conn, 11).expect("set user_version=11"); + Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations"); + + let column = get_column_info(&conn, "proxy_config", "retry_interval_seconds"); + assert_eq!(column.r#type, "INTEGER"); + assert_eq!(column.notnull, 1); + assert_eq!( + normalize_default(&column.default).as_deref(), + Some("0"), + "default must be 0 to preserve immediate-retry behavior" + ); + + // 老库已有数据保留,新列取默认值 0。 + let (max_retries, interval): (i64, i64) = conn + .query_row( + "SELECT max_retries, retry_interval_seconds FROM proxy_config WHERE app_type = 'codex'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("read migrated row"); + assert_eq!(max_retries, 3, "pre-existing data must survive migration"); + assert_eq!(interval, 0, "new column defaults to 0 on existing rows"); + + assert_eq!( + Database::get_user_version(&conn).expect("version after migration"), + SCHEMA_VERSION + ); +} + #[test] fn startup_migration_repairs_legacy_request_logs_before_session_index() { let conn = Connection::open_in_memory().expect("open memory db"); diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 05898b46..8023d064 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -39,6 +39,9 @@ pub struct ForwardOptions { pub max_retries: u32, pub request_timeout: Option, pub bypass_circuit_breaker: bool, + /// 连接阶段/首字前 transport 重试之间的固定等待时间。 + /// `None` 或 `Some(Duration::ZERO)` 表示立即重试(保持旧行为)。 + pub retry_interval_seconds: Option, } #[derive(Debug)] @@ -762,6 +765,7 @@ impl RequestForwarder { && is_retryable_transport_error(&error) { attempt += 1; + maybe_sleep_retry_interval(options.retry_interval_seconds).await; continue; } @@ -775,6 +779,7 @@ impl RequestForwarder { Err(_) => { if allow_transport_retry && attempt < options.max_retries { attempt += 1; + maybe_sleep_retry_interval(options.retry_interval_seconds).await; continue; } @@ -929,6 +934,7 @@ impl RequestForwarder { && is_retryable_transport_error(&error) { attempt += 1; + maybe_sleep_retry_interval(options.retry_interval_seconds).await; continue; } @@ -942,6 +948,7 @@ impl RequestForwarder { Err(_) => { if allow_transport_retry && attempt < options.max_retries { attempt += 1; + maybe_sleep_retry_interval(options.retry_interval_seconds).await; continue; } @@ -1111,6 +1118,18 @@ fn uses_internal_transport_retry(app_type: &AppType) -> bool { !matches!(app_type, AppType::Claude) } +/// 在两次 transport 重试之间按配置的固定间隔等待。 +/// +/// `None` 或 `Some(Duration::ZERO)` 时不 sleep(保持旧的立即重试行为,也不引入 +/// 额外的 async 让步)。 +async fn maybe_sleep_retry_interval(interval: Option) { + if let Some(interval) = interval { + if !interval.is_zero() { + tokio::time::sleep(interval).await; + } + } +} + fn is_retryable_transport_error(error: &reqwest::Error) -> bool { error.is_timeout() || error.is_connect() } diff --git a/src-tauri/src/proxy/forwarder/tests/error_paths.rs b/src-tauri/src/proxy/forwarder/tests/error_paths.rs index 39dc355a..e405e4ad 100644 --- a/src-tauri/src/proxy/forwarder/tests/error_paths.rs +++ b/src-tauri/src/proxy/forwarder/tests/error_paths.rs @@ -1,4 +1,7 @@ -use std::{sync::atomic::Ordering, time::Duration}; +use std::{ + sync::atomic::Ordering, + time::{Duration, Instant}, +}; use axum::http::{HeaderMap, StatusCode}; use serde_json::{json, Value}; @@ -10,6 +13,7 @@ use super::{ }; use crate::{ app_config::AppType, + provider::Provider, proxy::{ error::ProxyError, forwarder::{ForwardOptions, RequestForwarder, StreamingResponse}, @@ -43,6 +47,7 @@ async fn single_provider_buffered_claude_non_2xx_returns_upstream_error() { max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: false, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -97,6 +102,7 @@ async fn last_provider_429_returns_upstream_error() { max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: false, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -158,6 +164,7 @@ async fn last_streaming_provider_429_returns_upstream_error() { max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: false, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -200,6 +207,7 @@ async fn buffered_timeout_includes_body_read_budget_after_headers() { max_retries: 0, request_timeout: Some(Duration::from_millis(50)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -238,6 +246,7 @@ async fn buffered_body_timeout_after_response_does_not_failover() { max_retries: 0, request_timeout: Some(Duration::from_millis(50)), bypass_circuit_breaker: false, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -285,6 +294,7 @@ async fn buffered_transport_retry_shares_request_timeout_budget() { max_retries: 1, request_timeout: Some(Duration::from_millis(50)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -317,6 +327,7 @@ async fn buffered_connect_error_maps_to_forward_failed() { max_retries: 2, request_timeout: Some(Duration::from_secs(1)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -326,6 +337,94 @@ async fn buffered_connect_error_maps_to_forward_failed() { assert!(matches!(error, ProxyError::ForwardFailed(_))); } +/// 非 Claude app(Codex)的 provider 构造,base_url 指向 closed port 会触发连接拒绝 +/// (可重试的 transport 错误),且 `uses_internal_transport_retry(Codex) == true`, +/// 因此 forwarder 会真正进入 transport 重试循环。 +fn codex_provider(base_url: &str) -> Provider { + Provider::with_id( + "codex".to_string(), + "Codex Provider".to_string(), + json!({ + "base_url": base_url, + "apiKey": "codex-key", + }), + None, + ) +} + +#[tokio::test] +async fn buffered_transport_retry_sleeps_configured_interval_between_attempts() { + // 连接 closed port:连接拒绝是 is_connect() ⇒ 可重试的 transport 错误。 + // 用 Codex 而非 Claude:Claude 的 allow_transport_retry == false,根本不重试。 + let provider = codex_provider(&closed_base_url().await); + let (db, router) = test_router().await; + let forwarder = RequestForwarder::new(router).expect("create forwarder"); + db.save_provider("codex", &provider) + .expect("save provider for health tracking"); + + let body = json!({"model": "gpt-5.4", "input": "hi", "stream": false}); + let started = Instant::now(); + let _ = forwarder + .forward_buffered_response( + &AppType::Codex, + "/v1/chat/completions", + body, + &HeaderMap::new(), + vec![provider], + ForwardOptions { + max_retries: 1, + // request_timeout 远大于 interval*retries,确保落到 connect-error 重试分支 + // 而非 timeout 分支(两者都会 sleep,但我们要测的是 connect 重试路径)。 + request_timeout: Some(Duration::from_secs(5)), + bypass_circuit_breaker: true, + retry_interval_seconds: Some(Duration::from_millis(150)), + }, + RectifierConfig::default(), + ) + .await; + let elapsed = started.elapsed(); + + assert!( + elapsed >= Duration::from_millis(150), + "retry should sleep the configured interval; took {elapsed:?}" + ); +} + +#[tokio::test] +async fn buffered_transport_retry_with_zero_interval_does_not_sleep() { + // 对照组:interval 为 None 时两次连接失败之间不应 sleep,整体远小于 150ms。 + let provider = codex_provider(&closed_base_url().await); + let (db, router) = test_router().await; + let forwarder = RequestForwarder::new(router).expect("create forwarder"); + db.save_provider("codex", &provider) + .expect("save provider for health tracking"); + + let body = json!({"model": "gpt-5.4", "input": "hi", "stream": false}); + let started = Instant::now(); + let _ = forwarder + .forward_buffered_response( + &AppType::Codex, + "/v1/chat/completions", + body, + &HeaderMap::new(), + vec![provider], + ForwardOptions { + max_retries: 2, + request_timeout: Some(Duration::from_secs(5)), + bypass_circuit_breaker: true, + retry_interval_seconds: None, + }, + RectifierConfig::default(), + ) + .await; + let elapsed = started.elapsed(); + + assert!( + elapsed < Duration::from_millis(150), + "no interval ⇒ immediate retries, should be fast; took {elapsed:?}" + ); +} + #[tokio::test] async fn buffered_rectifier_retry_shares_request_timeout_budget() { let (base_url, hits, bodies, server) = spawn_delayed_scripted_upstream(vec![ @@ -371,6 +470,7 @@ async fn buffered_rectifier_retry_shares_request_timeout_budget() { max_retries: 0, request_timeout: Some(Duration::from_millis(50)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -442,6 +542,7 @@ async fn streaming_transport_timeout_fails_over_without_same_provider_retry() { max_retries: 1, request_timeout: Some(Duration::from_millis(50)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -499,6 +600,7 @@ async fn claude_streaming_success_path_does_not_trigger_rectifier_retry() { max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, RectifierConfig::default(), ) diff --git a/src-tauri/src/proxy/forwarder/tests/provider_failover.rs b/src-tauri/src/proxy/forwarder/tests/provider_failover.rs index f71271c4..3a395096 100644 --- a/src-tauri/src/proxy/forwarder/tests/provider_failover.rs +++ b/src-tauri/src/proxy/forwarder/tests/provider_failover.rs @@ -59,6 +59,7 @@ async fn single_provider_bypasses_open_breaker() { max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -112,6 +113,7 @@ async fn single_provider_respects_open_breaker_without_explicit_bypass_option() max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: false, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -181,6 +183,7 @@ async fn single_streaming_provider_respects_open_breaker_without_explicit_bypass max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: false, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -224,6 +227,7 @@ async fn claude_buffered_failover_uses_second_provider_and_per_provider_endpoint max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: false, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -297,6 +301,7 @@ async fn failover_enabled_single_queued_negative_provider_does_not_use_non_queue max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: false, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -366,6 +371,7 @@ async fn failover_enabled_multiple_queued_providers_transfer_by_queue_priority() max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: false, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -431,6 +437,7 @@ async fn failover_enabled_all_queued_providers_unavailable_fails_after_attemptin max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: false, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -488,6 +495,7 @@ async fn plain_buffered_400_fails_over_to_next_provider() { max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: false, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -565,6 +573,7 @@ async fn claude_buffered_rectifier_owned_400_stops_before_next_provider() { max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: false, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -642,6 +651,7 @@ async fn plain_streaming_422_json_error_fails_over_to_next_provider() { max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: false, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -703,6 +713,7 @@ async fn single_candidate_with_failover_enabled_respects_open_breaker() { max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: false, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -766,6 +777,7 @@ async fn skipped_candidates_preserve_last_attempted_upstream_response() { max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: false, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -830,6 +842,7 @@ async fn later_half_open_provider_permit_is_not_preclaimed_when_earlier_success_ max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: false, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -888,6 +901,7 @@ async fn claude_buffered_rectifier_retries_same_provider_on_invalid_signature() max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -956,6 +970,7 @@ async fn claude_openai_chat_budget_rectifier_retries_same_provider_with_transfor max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -1027,6 +1042,7 @@ async fn claude_streaming_rectifier_retries_same_provider_on_invalid_signature_e max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -1120,6 +1136,7 @@ async fn claude_streaming_rectifier_owned_400_stops_before_next_provider() { max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: false, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -1195,6 +1212,7 @@ async fn claude_streaming_openai_chat_budget_rectifier_retries_same_provider() { max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, RectifierConfig::default(), ) diff --git a/src-tauri/src/proxy/forwarder/tests/request_building.rs b/src-tauri/src/proxy/forwarder/tests/request_building.rs index 6ccace07..bc16e90a 100644 --- a/src-tauri/src/proxy/forwarder/tests/request_building.rs +++ b/src-tauri/src/proxy/forwarder/tests/request_building.rs @@ -61,6 +61,7 @@ async fn bedrock_claude_prepare_request_injects_optimizer_and_cache_breakpoints( max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -120,6 +121,7 @@ async fn non_bedrock_claude_prepare_request_skips_optimizer_and_cache_injection( max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -168,6 +170,7 @@ async fn non_copilot_claude_prepare_request_strips_one_m_suffix_after_mapping() max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, ) .await @@ -209,6 +212,7 @@ async fn deepseek_native_claude_prepare_request_normalizes_tool_thinking_history max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, RectifierConfig::default(), ) @@ -389,6 +393,7 @@ async fn claude_gemini_native_prepare_request_rewrites_url_body_and_auth() { max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, ) .await @@ -445,6 +450,7 @@ async fn claude_gemini_native_prepare_request_preserves_opaque_full_url() { max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, ) .await @@ -503,6 +509,7 @@ async fn streaming_passthrough_prepare_request_forces_identity_accept_encoding() max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, ) .await @@ -575,6 +582,7 @@ async fn codex_oauth_prepare_request_injects_client_session_headers() { max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, ) .await @@ -627,6 +635,7 @@ async fn codex_oauth_prepare_request_skips_generated_session_headers() { max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, ) .await @@ -687,6 +696,7 @@ async fn codex_oauth_prepare_request_errors_without_available_account() { max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, ) .await @@ -730,6 +740,7 @@ async fn github_copilot_prepare_request_uses_responses_for_openai_vendor_model() max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, ) .await @@ -793,6 +804,7 @@ async fn github_copilot_prepare_request_uses_chat_for_anthropic_vendor_model() { max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, ) .await @@ -849,6 +861,7 @@ async fn github_copilot_prepare_request_detects_copilot_base_url_without_provide max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, ) .await @@ -903,6 +916,7 @@ async fn github_copilot_prepare_request_preserves_full_url_relay() { max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, ) .await @@ -979,6 +993,7 @@ async fn github_copilot_prepare_request_sets_agent_initiator_for_tool_results() max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, ) .await @@ -1032,6 +1047,7 @@ async fn github_copilot_prepare_request_sets_subagent_headers_and_interaction_id max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, ) .await @@ -1085,6 +1101,7 @@ async fn github_copilot_prepare_request_uses_x_session_id_for_interaction_id_fal max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, ) .await @@ -1132,6 +1149,7 @@ async fn github_copilot_prepare_request_downgrades_warmup_model() { max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, ) .await @@ -1191,6 +1209,7 @@ async fn github_copilot_prepare_request_strips_thinking_before_transform() { max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, ) .await @@ -1255,6 +1274,7 @@ async fn github_copilot_prepare_request_overrides_client_fingerprint_headers() { max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, ) .await @@ -1324,6 +1344,7 @@ async fn github_copilot_prepare_request_disabled_optimizer_keeps_default_headers max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, ) .await @@ -1373,6 +1394,7 @@ async fn codex_oauth_prepare_request_rejects_proxy_managed_placeholder_header() max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, ) .await @@ -1406,6 +1428,7 @@ async fn non_managed_upstream_allows_proxy_managed_placeholder_guard() { max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, ) .await @@ -1443,6 +1466,7 @@ async fn codex_chat_prepare_request_rewrites_responses_to_chat_completions() { max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, ) .await @@ -1491,6 +1515,7 @@ async fn codex_chat_prepare_request_preserves_responses_query() { max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, ) .await @@ -1525,6 +1550,7 @@ async fn codex_chat_prepare_request_preserves_responses_compact_query() { max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, ) .await @@ -1559,6 +1585,7 @@ async fn codex_chat_prepare_request_uses_provider_chat_base_without_forcing_v1() max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, ) .await @@ -1596,6 +1623,7 @@ async fn codex_chat_prepare_request_preserves_full_chat_endpoint_base_url() { max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, ) .await @@ -1633,6 +1661,7 @@ async fn codex_chat_prepare_request_preserves_query_with_full_chat_endpoint_base max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, ) .await @@ -1665,6 +1694,7 @@ async fn build_request( max_retries: 0, request_timeout: Some(Duration::from_secs(2)), bypass_circuit_breaker: true, + retry_interval_seconds: None, }, ) .await diff --git a/src-tauri/src/proxy/handler_context.rs b/src-tauri/src/proxy/handler_context.rs index e5fa03e9..fb0fa7d6 100644 --- a/src-tauri/src/proxy/handler_context.rs +++ b/src-tauri/src/proxy/handler_context.rs @@ -125,6 +125,15 @@ impl HandlerContext { self.app_proxy.non_streaming_timeout as u64, )) } + + /// 连接阶段/首字前 transport 重试之间的固定等待时间。 + /// `0` 表示立即重试(返回 `None`,forwarder 不 sleep)。 + pub fn retry_interval(&self) -> Option { + match self.app_proxy.retry_interval_seconds { + 0 => None, + seconds => Some(Duration::from_secs(seconds as u64)), + } + } } #[cfg(test)] diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 9554e66d..be223220 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -162,6 +162,7 @@ async fn handle_claude_request( max_retries: context.app_proxy.max_retries, request_timeout: first_byte_timeout, bypass_circuit_breaker: !context.app_proxy.auto_failover_enabled, + retry_interval_seconds: context.retry_interval(), }; let forward_result = match forwarder .forward_response_detailed( @@ -278,6 +279,7 @@ async fn handle_claude_request( max_retries: context.app_proxy.max_retries, request_timeout: context.non_streaming_timeout(), bypass_circuit_breaker: !context.app_proxy.auto_failover_enabled, + retry_interval_seconds: context.retry_interval(), }; let forward_result = match forwarder @@ -497,12 +499,14 @@ async fn handle_passthrough_request( max_retries: context.app_proxy.max_retries, request_timeout: context.streaming_first_byte_timeout(), bypass_circuit_breaker: !context.app_proxy.auto_failover_enabled, + retry_interval_seconds: context.retry_interval(), } } else { ForwardOptions { max_retries: context.app_proxy.max_retries, request_timeout: context.non_streaming_timeout(), bypass_circuit_breaker: !context.app_proxy.auto_failover_enabled, + retry_interval_seconds: context.retry_interval(), } }; diff --git a/src-tauri/src/proxy/types.rs b/src-tauri/src/proxy/types.rs index 19da6211..c97b9cdd 100644 --- a/src-tauri/src/proxy/types.rs +++ b/src-tauri/src/proxy/types.rs @@ -208,6 +208,8 @@ pub struct AppProxyConfig { pub auto_failover_enabled: bool, /// 最大重试次数 pub max_retries: u32, + /// 重试间隔(秒)- 连接阶段/首字前 transport 重试之间的固定等待时间,0 表示立即重试 + pub retry_interval_seconds: u32, /// 流式首字超时(秒) pub streaming_first_byte_timeout: u32, /// 流式静默超时(秒) diff --git a/src-tauri/tests/proxy_database.rs b/src-tauri/tests/proxy_database.rs index e6f37a3f..599084e0 100644 --- a/src-tauri/tests/proxy_database.rs +++ b/src-tauri/tests/proxy_database.rs @@ -278,3 +278,26 @@ async fn update_proxy_config_for_app_preserves_failover_with_non_empty_queue( assert_eq!(db.get_proxy_flags_sync("claude"), (true, true)); Ok(()) } + +#[tokio::test] +async fn retry_interval_seconds_round_trips_per_app() -> Result<(), AppError> { + let db = Database::memory()?; + + // Fresh DB seeds retry_interval_seconds = 0 (backward-compatible immediate retry). + let initial = db.get_proxy_config_for_app("codex").await?; + assert_eq!(initial.retry_interval_seconds, 0); + + // Set + persist + re-read. + let mut config = db.get_proxy_config_for_app("codex").await?; + config.retry_interval_seconds = 7; + db.update_proxy_config_for_app(config).await?; + + let reloaded = db.get_proxy_config_for_app("codex").await?; + assert_eq!(reloaded.retry_interval_seconds, 7); + + // Other apps are independent (per-app storage). + let claude = db.get_proxy_config_for_app("claude").await?; + assert_eq!(claude.retry_interval_seconds, 0); + + Ok(()) +} diff --git a/src-tauri/tests/proxy_retry_interval.rs b/src-tauri/tests/proxy_retry_interval.rs new file mode 100644 index 00000000..e463f945 --- /dev/null +++ b/src-tauri/tests/proxy_retry_interval.rs @@ -0,0 +1,78 @@ +//! CLI 层面的可配置重试间隔端到端测试。 +//! +//! 验证 `cc-switch proxy config --retry-interval-seconds ` 能写入并持久化到 +//! proxy_config(per-app),且进程重启(重新打开 DB)后仍生效。 + +#![allow(clippy::await_holding_lock)] + +use serial_test::serial; + +use cc_switch_lib::{cli::commands::proxy::ProxyCommand, AppType, Database}; + +#[path = "support.rs"] +mod support; +use support::{ensure_test_home, lock_test_mutex}; + +// proxy::execute 内部自建 tokio runtime 并 block_on,因此必须在同步测试里调用, +// 不能放在 #[tokio::test] 之中(否则 "Cannot start a runtime from within a runtime")。 +#[test] +#[serial] +fn proxy_config_retry_interval_persists_across_reopen() { + let _guard = lock_test_mutex(); + ensure_test_home(); + + // 通过 CLI 子命令写入:configure_proxy 走 get_proxy_config_for_app + update_proxy_config_for_app。 + cc_switch_lib::cli::commands::proxy::execute( + ProxyCommand::Config { + listen_address: None, + listen_port: None, + retry_interval_seconds: Some(9), + }, + Some(AppType::Codex), + ) + .expect("proxy config --retry-interval-seconds should succeed"); + + // 模拟 daemon 重启:重新打开同一个 DB(CC_SWITCH_CONFIG_DIR 不变),再异步读取验证。 + let rt = tokio::runtime::Runtime::new().expect("create tokio runtime for reads"); + rt.block_on(async { + let db = Database::init().expect("reopen database after cli write"); + let codex = db + .get_proxy_config_for_app("codex") + .await + .expect("read codex proxy config"); + assert_eq!( + codex.retry_interval_seconds, 9, + "CLI setter must persist retry_interval_seconds per app" + ); + + // 其它 app 不受影响(per-app 存储)。 + let claude = db + .get_proxy_config_for_app("claude") + .await + .expect("read claude proxy config"); + assert_eq!(claude.retry_interval_seconds, 0); + }); +} + +#[test] +#[serial] +fn proxy_config_retry_interval_rejects_out_of_range() { + let _guard = lock_test_mutex(); + ensure_test_home(); + + let err = cc_switch_lib::cli::commands::proxy::execute( + ProxyCommand::Config { + listen_address: None, + listen_port: None, + retry_interval_seconds: Some(u32::MAX), + }, + Some(AppType::Codex), + ) + .expect_err("out-of-range interval should be rejected"); + + let msg = format!("{err}"); + assert!( + msg.contains("exceeds maximum"), + "expected range-violation error, got: {msg}" + ); +}