Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <N>` (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
Expand Down
121 changes: 112 additions & 9 deletions src-tauri/src/cli/commands/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ pub enum ProxyCommand {
/// Set the selected app's daemon worker listen port
#[arg(long)]
listen_port: Option<u16>,

/// Set the selected app's fixed transport-retry interval in seconds (0 = immediate)
#[arg(long)]
retry_interval_seconds: Option<u32>,
},

/// Start the local proxy in the foreground for debugging
Expand Down Expand Up @@ -60,7 +64,13 @@ pub fn execute(cmd: ProxyCommand, app: Option<AppType>) -> 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,
Expand Down Expand Up @@ -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::<Vec<_>>();

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}");
}

Expand Down Expand Up @@ -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<String>,
listen_port: Option<u16>,
retry_interval_seconds: Option<u32>,
) -> 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());
Expand All @@ -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());
Expand Down Expand Up @@ -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(())
}

Expand Down Expand Up @@ -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<String> {
let current_providers = AppType::all()
.map(|app| {
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -638,6 +706,25 @@ fn build_auto_failover_status_lines(state: &AppState) -> Vec<String> {
.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<String> {
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};
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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!(
Expand Down
9 changes: 7 additions & 2 deletions src-tauri/src/database/dao/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ fn default_app_proxy_config(app_type: impl Into<String>) -> 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,
Expand Down Expand Up @@ -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| {
Expand All @@ -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,
})
},
)
Expand Down Expand Up @@ -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| {
Expand All @@ -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,
})
},
)
Expand Down Expand Up @@ -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![
Expand All @@ -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()))?;
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/database/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions src-tauri/src/database/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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'))
Expand Down Expand Up @@ -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 返回的模型名称标准化后一致
Expand Down
Loading