From 032c86e2b976d3826e39aad44b1964b8ad0fd08d Mon Sep 17 00:00:00 2001
From: mxyhi
Date: Sun, 8 Mar 2026 10:19:31 +0800
Subject: [PATCH 1/7] feat(core): make retry cooldown configurable
---
.../token_proxy_core/src/proxy/config/mod.rs | 2 +
.../src/proxy/config/types.rs | 15 ++
.../src/proxy/config/types.test.rs | 7 +
.../token_proxy_core/src/proxy/http.test.rs | 2 +
.../token_proxy_core/src/proxy/server.test.rs | 231 +++++++++++++++++-
crates/token_proxy_core/src/proxy/service.rs | 4 +-
.../src/proxy/upstream.test.rs | 43 +++-
.../src/proxy/upstream/result.rs | 16 +-
.../src/proxy/upstream/utils.rs | 31 ++-
.../src/proxy/upstream_selector.rs | 13 +-
.../src/proxy/upstream_selector.test.rs | 18 +-
11 files changed, 348 insertions(+), 34 deletions(-)
diff --git a/crates/token_proxy_core/src/proxy/config/mod.rs b/crates/token_proxy_core/src/proxy/config/mod.rs
index 34d0d0f..db4f344 100644
--- a/crates/token_proxy_core/src/proxy/config/mod.rs
+++ b/crates/token_proxy_core/src/proxy/config/mod.rs
@@ -5,6 +5,7 @@ mod normalize;
mod types;
use crate::paths::TokenProxyPaths;
+use std::time::Duration;
const DEFAULT_MAX_REQUEST_BODY_BYTES: u64 = 20 * 1024 * 1024;
@@ -67,6 +68,7 @@ fn build_runtime_config(config: ProxyConfigFile) -> Result
local_api_key: config.local_api_key,
log_level,
max_request_body_bytes,
+ retryable_failure_cooldown: Duration::from_secs(config.retryable_failure_cooldown_secs),
upstream_strategy: config.upstream_strategy,
upstreams,
kiro_preferred_endpoint: config.kiro_preferred_endpoint,
diff --git a/crates/token_proxy_core/src/proxy/config/types.rs b/crates/token_proxy_core/src/proxy/config/types.rs
index 42d57b4..f536182 100644
--- a/crates/token_proxy_core/src/proxy/config/types.rs
+++ b/crates/token_proxy_core/src/proxy/config/types.rs
@@ -30,6 +30,14 @@ fn default_log_level() -> LogLevel {
LogLevel::Silent
}
+fn default_retryable_failure_cooldown_secs() -> u64 {
+ 15
+}
+
+fn is_default_retryable_failure_cooldown_secs(value: &u64) -> bool {
+ *value == default_retryable_failure_cooldown_secs()
+}
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InboundApiFormat {
@@ -182,6 +190,11 @@ pub struct ProxyConfigFile {
pub log_level: LogLevel,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_request_body_bytes: Option,
+ #[serde(
+ default = "default_retryable_failure_cooldown_secs",
+ skip_serializing_if = "is_default_retryable_failure_cooldown_secs"
+ )]
+ pub retryable_failure_cooldown_secs: u64,
#[serde(default)]
pub tray_token_rate: TrayTokenRateConfig,
#[serde(default)]
@@ -204,6 +217,7 @@ impl Default for ProxyConfigFile {
antigravity_user_agent: None,
log_level: LogLevel::default(),
max_request_body_bytes: None,
+ retryable_failure_cooldown_secs: default_retryable_failure_cooldown_secs(),
tray_token_rate: TrayTokenRateConfig::default(),
upstream_strategy: UpstreamStrategy::PriorityFillFirst,
upstreams: Vec::new(),
@@ -218,6 +232,7 @@ pub struct ProxyConfig {
pub local_api_key: Option,
pub log_level: LogLevel,
pub max_request_body_bytes: usize,
+ pub retryable_failure_cooldown: std::time::Duration,
pub upstream_strategy: UpstreamStrategy,
pub upstreams: HashMap,
pub kiro_preferred_endpoint: Option,
diff --git a/crates/token_proxy_core/src/proxy/config/types.test.rs b/crates/token_proxy_core/src/proxy/config/types.test.rs
index 3630fe2..9b951cd 100644
--- a/crates/token_proxy_core/src/proxy/config/types.test.rs
+++ b/crates/token_proxy_core/src/proxy/config/types.test.rs
@@ -158,3 +158,10 @@ fn test_upstream_url() {
"https://api.example.com/openai/v1/messages"
);
}
+
+#[test]
+fn proxy_config_file_defaults_retryable_failure_cooldown_to_15_seconds() {
+ let config = ProxyConfigFile::default();
+
+ assert_eq!(config.retryable_failure_cooldown_secs, 15);
+}
diff --git a/crates/token_proxy_core/src/proxy/http.test.rs b/crates/token_proxy_core/src/proxy/http.test.rs
index 2a873c4..dfdc4f1 100644
--- a/crates/token_proxy_core/src/proxy/http.test.rs
+++ b/crates/token_proxy_core/src/proxy/http.test.rs
@@ -10,6 +10,7 @@ fn config_with_local(key: &str) -> ProxyConfig {
local_api_key: Some(key.to_string()),
log_level: LogLevel::Silent,
max_request_body_bytes: 1024,
+ retryable_failure_cooldown: std::time::Duration::from_secs(15),
upstream_strategy: crate::proxy::config::UpstreamStrategy::PriorityFillFirst,
upstreams: HashMap::new(),
kiro_preferred_endpoint: None,
@@ -24,6 +25,7 @@ fn config_without_local() -> ProxyConfig {
local_api_key: None,
log_level: LogLevel::Silent,
max_request_body_bytes: 1024,
+ retryable_failure_cooldown: std::time::Duration::from_secs(15),
upstream_strategy: crate::proxy::config::UpstreamStrategy::PriorityFillFirst,
upstreams: HashMap::new(),
kiro_preferred_endpoint: None,
diff --git a/crates/token_proxy_core/src/proxy/server.test.rs b/crates/token_proxy_core/src/proxy/server.test.rs
index 1a1748e..f24566e 100644
--- a/crates/token_proxy_core/src/proxy/server.test.rs
+++ b/crates/token_proxy_core/src/proxy/server.test.rs
@@ -123,6 +123,7 @@ fn config_with_runtime_upstreams(
local_api_key: None,
log_level: LogLevel::Silent,
max_request_body_bytes: 20 * 1024 * 1024,
+ retryable_failure_cooldown: std::time::Duration::from_secs(15),
upstream_strategy: UpstreamStrategy::PriorityRoundRobin,
upstreams: provider_map,
kiro_preferred_endpoint: None,
@@ -263,12 +264,16 @@ async fn build_test_state_handle(config: ProxyConfig, data_dir: PathBuf) -> Prox
}
}
}
+ let retryable_failure_cooldown = config.retryable_failure_cooldown;
let state = Arc::new(ProxyState {
config,
http_clients: super::super::http_client::ProxyHttpClients::new().expect("http clients"),
log: Arc::new(super::super::log::LogWriter::new(None)),
cursors,
- upstream_selector: super::super::upstream_selector::UpstreamSelectorRuntime::new(),
+ upstream_selector:
+ super::super::upstream_selector::UpstreamSelectorRuntime::new_with_cooldown(
+ retryable_failure_cooldown,
+ ),
request_detail: Arc::new(super::super::request_detail::RequestDetailCapture::new(
None,
)),
@@ -416,6 +421,48 @@ fn responses_request_falls_back_from_403_to_codex() {
));
}
+#[test]
+fn responses_request_falls_back_from_401_to_codex() {
+ run_async(assert_responses_retry_fallback_status(
+ StatusCode::UNAUTHORIZED,
+ ));
+}
+
+#[test]
+fn responses_request_falls_back_from_404_to_codex() {
+ run_async(assert_responses_retry_fallback_status(
+ StatusCode::NOT_FOUND,
+ ));
+}
+
+#[test]
+fn responses_request_falls_back_from_408_to_codex() {
+ run_async(assert_responses_retry_fallback_status(
+ StatusCode::REQUEST_TIMEOUT,
+ ));
+}
+
+#[test]
+fn responses_request_falls_back_from_422_to_codex() {
+ run_async(assert_responses_retry_fallback_status(
+ StatusCode::UNPROCESSABLE_ENTITY,
+ ));
+}
+
+#[test]
+fn responses_request_falls_back_from_504_to_codex() {
+ run_async(assert_responses_retry_fallback_status(
+ StatusCode::GATEWAY_TIMEOUT,
+ ));
+}
+
+#[test]
+fn responses_request_falls_back_from_524_to_codex() {
+ run_async(assert_responses_retry_fallback_status(
+ StatusCode::from_u16(524).expect("524"),
+ ));
+}
+
#[test]
fn responses_request_skips_recently_failed_same_provider_upstream() {
run_async(async {
@@ -500,6 +547,90 @@ fn responses_request_skips_recently_failed_same_provider_upstream() {
});
}
+#[test]
+fn responses_request_cooldowns_same_provider_upstream_after_401() {
+ run_async(async {
+ let primary = spawn_mock_upstream(
+ StatusCode::UNAUTHORIZED,
+ json!({
+ "error": { "message": "primary unauthorized" }
+ }),
+ )
+ .await;
+ let secondary = spawn_mock_upstream(
+ StatusCode::OK,
+ json!({
+ "id": "resp_from_secondary",
+ "object": "response",
+ "created_at": 123,
+ "model": "gpt-5",
+ "status": "completed",
+ "output": [
+ {
+ "type": "message",
+ "id": "msg_1",
+ "status": "completed",
+ "role": "assistant",
+ "content": [
+ { "type": "output_text", "text": "from secondary" }
+ ]
+ }
+ ],
+ "usage": { "input_tokens": 1, "output_tokens": 2, "total_tokens": 3 }
+ }),
+ )
+ .await;
+
+ let mut config = config_with_runtime_upstreams(&[
+ (
+ PROVIDER_RESPONSES,
+ 10,
+ "responses-primary",
+ primary.base_url.as_str(),
+ FORMATS_RESPONSES,
+ ),
+ (
+ PROVIDER_RESPONSES,
+ 10,
+ "responses-secondary",
+ secondary.base_url.as_str(),
+ FORMATS_RESPONSES,
+ ),
+ ]);
+ config.upstream_strategy = UpstreamStrategy::PriorityFillFirst;
+
+ let data_dir = next_test_data_dir("responses_same_provider_cooldown_401");
+ let state = build_test_state_handle(config, data_dir.clone()).await;
+
+ let (first_status, first_json) = send_responses_request(state.clone()).await;
+ let (second_status, second_json) = send_responses_request(state).await;
+
+ let primary_requests = primary.requests();
+ let secondary_requests = secondary.requests();
+
+ primary.abort();
+ secondary.abort();
+ let _ = std::fs::remove_dir_all(&data_dir);
+
+ assert_eq!(first_status, StatusCode::OK);
+ assert_eq!(second_status, StatusCode::OK);
+ assert_eq!(
+ first_json["output"][0]["content"][0]["text"].as_str(),
+ Some("from secondary")
+ );
+ assert_eq!(
+ second_json["output"][0]["content"][0]["text"].as_str(),
+ Some("from secondary")
+ );
+ assert_eq!(
+ primary_requests.len(),
+ 1,
+ "401 should cool down the upstream to avoid repeatedly hitting the same invalid account"
+ );
+ assert_eq!(secondary_requests.len(), 2);
+ });
+}
+
#[test]
fn responses_request_does_not_cooldown_same_provider_upstream_after_400() {
run_async(async {
@@ -584,6 +715,104 @@ fn responses_request_does_not_cooldown_same_provider_upstream_after_400() {
});
}
+#[test]
+fn responses_request_reload_resets_existing_cooldown_and_applies_new_duration() {
+ run_async(async {
+ let primary = spawn_mock_upstream(
+ StatusCode::UNAUTHORIZED,
+ json!({
+ "error": { "message": "primary unauthorized" }
+ }),
+ )
+ .await;
+ let secondary = spawn_mock_upstream(
+ StatusCode::OK,
+ json!({
+ "id": "resp_from_secondary",
+ "object": "response",
+ "created_at": 123,
+ "model": "gpt-5",
+ "status": "completed",
+ "output": [
+ {
+ "type": "message",
+ "id": "msg_1",
+ "status": "completed",
+ "role": "assistant",
+ "content": [
+ { "type": "output_text", "text": "from secondary" }
+ ]
+ }
+ ],
+ "usage": { "input_tokens": 1, "output_tokens": 2, "total_tokens": 3 }
+ }),
+ )
+ .await;
+
+ let mut config = config_with_runtime_upstreams(&[
+ (
+ PROVIDER_RESPONSES,
+ 10,
+ "responses-primary",
+ primary.base_url.as_str(),
+ FORMATS_RESPONSES,
+ ),
+ (
+ PROVIDER_RESPONSES,
+ 10,
+ "responses-secondary",
+ secondary.base_url.as_str(),
+ FORMATS_RESPONSES,
+ ),
+ ]);
+ config.upstream_strategy = UpstreamStrategy::PriorityFillFirst;
+ config.retryable_failure_cooldown = std::time::Duration::from_secs(15);
+
+ let data_dir = next_test_data_dir("responses_same_provider_reload_resets_cooldown");
+ let state = build_test_state_handle(config.clone(), data_dir.clone()).await;
+
+ let _ = send_responses_request(state.clone()).await;
+ let _ = send_responses_request(state.clone()).await;
+
+ let primary_requests_before_reload = primary.requests();
+ assert_eq!(
+ primary_requests_before_reload.len(),
+ 1,
+ "pre-reload second request should skip cooled-down upstream"
+ );
+
+ let mut reloaded_config = config;
+ reloaded_config.retryable_failure_cooldown = std::time::Duration::ZERO;
+ let reloaded_state_handle =
+ build_test_state_handle(reloaded_config, data_dir.clone()).await;
+ let reloaded_state = {
+ let guard = reloaded_state_handle.read().await;
+ guard.clone()
+ };
+ {
+ let mut guard = state.write().await;
+ *guard = reloaded_state;
+ }
+
+ let _ = send_responses_request(state.clone()).await;
+ let _ = send_responses_request(state).await;
+
+ let primary_requests = primary.requests();
+ let secondary_requests = secondary.requests();
+
+ primary.abort();
+ secondary.abort();
+ let _ = std::fs::remove_dir_all(&data_dir);
+
+ assert_eq!(
+ primary_requests.len(),
+ 3,
+ "reload should clear old cooldowns, and zero cooldown should allow primary to be retried on every later request"
+ );
+ assert_eq!(secondary_requests.len(), 4);
+ });
+}
+
#[test]
fn chat_fallback_requires_format_conversion_enabled() {
let config = config_with_providers(&[(PROVIDER_RESPONSES, FORMATS_RESPONSES)]);
diff --git a/crates/token_proxy_core/src/proxy/service.rs b/crates/token_proxy_core/src/proxy/service.rs
index cc7c1fc..7d8732e 100644
--- a/crates/token_proxy_core/src/proxy/service.rs
+++ b/crates/token_proxy_core/src/proxy/service.rs
@@ -365,11 +365,13 @@ async fn build_proxy_state(
let codex_accounts = ctx.codex_accounts.clone();
let antigravity_accounts = ctx.antigravity_accounts.clone();
Ok(Arc::new(ProxyState {
+ upstream_selector: super::upstream_selector::UpstreamSelectorRuntime::new_with_cooldown(
+ config.retryable_failure_cooldown,
+ ),
config,
http_clients,
log,
cursors,
- upstream_selector: super::upstream_selector::UpstreamSelectorRuntime::new(),
request_detail,
token_rate,
kiro_accounts,
diff --git a/crates/token_proxy_core/src/proxy/upstream.test.rs b/crates/token_proxy_core/src/proxy/upstream.test.rs
index af7492d..ed68e1b 100644
--- a/crates/token_proxy_core/src/proxy/upstream.test.rs
+++ b/crates/token_proxy_core/src/proxy/upstream.test.rs
@@ -8,14 +8,49 @@ fn retryable_status_matches_proxy_policy() {
assert!(is_retryable_status(StatusCode::TOO_MANY_REQUESTS));
assert!(is_retryable_status(StatusCode::TEMPORARY_REDIRECT));
assert!(is_retryable_status(StatusCode::INTERNAL_SERVER_ERROR));
+ assert!(is_retryable_status(StatusCode::UNAUTHORIZED));
+ assert!(is_retryable_status(StatusCode::NOT_FOUND));
+ assert!(is_retryable_status(StatusCode::REQUEST_TIMEOUT));
+ assert!(is_retryable_status(StatusCode::UNPROCESSABLE_ENTITY));
+ assert!(is_retryable_status(StatusCode::GATEWAY_TIMEOUT));
+ assert!(is_retryable_status(
+ StatusCode::from_u16(524).expect("524")
+ ));
+}
- // Exclude 504/524 timeouts from retries.
- assert!(!is_retryable_status(StatusCode::GATEWAY_TIMEOUT));
- assert!(!is_retryable_status(
+#[test]
+fn cooldown_status_matches_proxy_policy() {
+ assert!(result::should_cooldown_retryable_status(
+ StatusCode::UNAUTHORIZED
+ ));
+ assert!(result::should_cooldown_retryable_status(
+ StatusCode::FORBIDDEN
+ ));
+ assert!(result::should_cooldown_retryable_status(
+ StatusCode::REQUEST_TIMEOUT
+ ));
+ assert!(result::should_cooldown_retryable_status(
+ StatusCode::TOO_MANY_REQUESTS
+ ));
+ assert!(result::should_cooldown_retryable_status(
+ StatusCode::GATEWAY_TIMEOUT
+ ));
+ assert!(result::should_cooldown_retryable_status(
StatusCode::from_u16(524).expect("524")
));
- assert!(!is_retryable_status(StatusCode::UNAUTHORIZED));
+ assert!(!result::should_cooldown_retryable_status(
+ StatusCode::BAD_REQUEST
+ ));
+ assert!(!result::should_cooldown_retryable_status(
+ StatusCode::NOT_FOUND
+ ));
+ assert!(!result::should_cooldown_retryable_status(
+ StatusCode::UNPROCESSABLE_ENTITY
+ ));
+ assert!(!result::should_cooldown_retryable_status(
+ StatusCode::TEMPORARY_REDIRECT
+ ));
}
#[test]
diff --git a/crates/token_proxy_core/src/proxy/upstream/result.rs b/crates/token_proxy_core/src/proxy/upstream/result.rs
index 3da4bbb..73ed666 100644
--- a/crates/token_proxy_core/src/proxy/upstream/result.rs
+++ b/crates/token_proxy_core/src/proxy/upstream/result.rs
@@ -13,6 +13,18 @@ use crate::proxy::response::{build_proxy_response, build_proxy_response_buffered
use crate::proxy::token_rate::TokenRateTracker;
use crate::proxy::RequestMeta;
+pub(super) fn should_cooldown_retryable_status(status: StatusCode) -> bool {
+ // cooldown 只用于“更像上游账号/节点短时异常”的错误,避免把请求内容问题扩散到后续请求。
+ // 因此 400/404/422/307 虽然可在当前请求内换路重试,但不会跨请求冷却整个 upstream。
+ matches!(
+ status,
+ StatusCode::UNAUTHORIZED
+ | StatusCode::FORBIDDEN
+ | StatusCode::REQUEST_TIMEOUT
+ | StatusCode::TOO_MANY_REQUESTS
+ ) || status.is_server_error()
+}
+
pub(super) async fn handle_upstream_result(
upstream_res: Result,
meta: &RequestMeta,
@@ -45,9 +57,7 @@ pub(super) async fn handle_upstream_result(
message: format!("Upstream responded with {}", response.status()),
response: Some(response),
is_timeout: false,
- should_cooldown: status == StatusCode::FORBIDDEN
- || status == StatusCode::TOO_MANY_REQUESTS
- || status.is_server_error(),
+ should_cooldown: should_cooldown_retryable_status(status),
}
}
Ok(res) => {
diff --git a/crates/token_proxy_core/src/proxy/upstream/utils.rs b/crates/token_proxy_core/src/proxy/upstream/utils.rs
index d375fec..7369b46 100644
--- a/crates/token_proxy_core/src/proxy/upstream/utils.rs
+++ b/crates/token_proxy_core/src/proxy/upstream/utils.rs
@@ -63,20 +63,19 @@ pub(super) fn is_retryable_error(err: &reqwest::Error) -> bool {
}
pub(super) fn is_retryable_status(status: StatusCode) -> bool {
- // 基于 new-api 的重试策略:400/429/307/5xx(排除 504/524);额外允许 403 触发 fallback。
- if status == StatusCode::BAD_REQUEST
- || status == StatusCode::FORBIDDEN
- || status == StatusCode::TOO_MANY_REQUESTS
- || status == StatusCode::TEMPORARY_REDIRECT
- {
- return true;
- }
- if status == StatusCode::GATEWAY_TIMEOUT {
- return false;
- }
- if status.as_u16() == 524 {
- // Cloudflare timeout.
- return false;
- }
- status.is_server_error()
+ // 为了尽量提供“无反馈”的自动切换体验,以下错误都允许继续尝试下一个渠道:
+ // - 显式可回退的鉴权/路由/请求超时/语义校验错误:401/404/408/422
+ // - 配额/权限/重定向:400/403/429/307
+ // - 所有 5xx,包括 504 与 Cloudflare 524。
+ matches!(
+ status,
+ StatusCode::BAD_REQUEST
+ | StatusCode::UNAUTHORIZED
+ | StatusCode::FORBIDDEN
+ | StatusCode::NOT_FOUND
+ | StatusCode::REQUEST_TIMEOUT
+ | StatusCode::UNPROCESSABLE_ENTITY
+ | StatusCode::TOO_MANY_REQUESTS
+ | StatusCode::TEMPORARY_REDIRECT
+ ) || status.is_server_error()
}
diff --git a/crates/token_proxy_core/src/proxy/upstream_selector.rs b/crates/token_proxy_core/src/proxy/upstream_selector.rs
index 4f75c7b..47e8e02 100644
--- a/crates/token_proxy_core/src/proxy/upstream_selector.rs
+++ b/crates/token_proxy_core/src/proxy/upstream_selector.rs
@@ -6,8 +6,6 @@ use std::{
use super::{config::UpstreamRuntime, config::UpstreamStrategy};
-const RETRYABLE_FAILURE_COOLDOWN: Duration = Duration::from_secs(15);
-
#[derive(Hash, PartialEq, Eq)]
struct CooldownKey {
provider: String,
@@ -23,14 +21,17 @@ impl CooldownKey {
}
}
-#[derive(Default)]
pub(crate) struct UpstreamSelectorRuntime {
+ retryable_failure_cooldown: Duration,
cooldowns: Mutex>,
}
impl UpstreamSelectorRuntime {
- pub(crate) fn new() -> Self {
- Self::default()
+ pub(crate) fn new_with_cooldown(retryable_failure_cooldown: Duration) -> Self {
+ Self {
+ retryable_failure_cooldown,
+ cooldowns: Mutex::new(HashMap::new()),
+ }
}
pub(crate) fn order_group(
@@ -53,7 +54,7 @@ impl UpstreamSelectorRuntime {
self.mark_cooldown_until(
provider,
upstream_id,
- Instant::now() + RETRYABLE_FAILURE_COOLDOWN,
+ Instant::now() + self.retryable_failure_cooldown,
);
}
diff --git a/crates/token_proxy_core/src/proxy/upstream_selector.test.rs b/crates/token_proxy_core/src/proxy/upstream_selector.test.rs
index 3ce57b6..7e5cd87 100644
--- a/crates/token_proxy_core/src/proxy/upstream_selector.test.rs
+++ b/crates/token_proxy_core/src/proxy/upstream_selector.test.rs
@@ -22,7 +22,7 @@ fn runtime(id: &str) -> UpstreamRuntime {
#[test]
fn cooled_upstream_moves_behind_ready_candidates() {
- let selector = UpstreamSelectorRuntime::new();
+ let selector = UpstreamSelectorRuntime::new_with_cooldown(Duration::from_secs(15));
let items = vec![runtime("a"), runtime("b"), runtime("c")];
selector.mark_cooldown_until("responses", "a", Instant::now() + Duration::from_secs(10));
@@ -34,7 +34,7 @@ fn cooled_upstream_moves_behind_ready_candidates() {
#[test]
fn all_cooled_upstreams_probe_earliest_expiry_first() {
- let selector = UpstreamSelectorRuntime::new();
+ let selector = UpstreamSelectorRuntime::new_with_cooldown(Duration::from_secs(15));
let items = vec![runtime("a"), runtime("b"), runtime("c")];
selector.mark_cooldown_until("responses", "a", Instant::now() + Duration::from_secs(30));
@@ -48,7 +48,7 @@ fn all_cooled_upstreams_probe_earliest_expiry_first() {
#[test]
fn clear_cooldown_restores_base_order() {
- let selector = UpstreamSelectorRuntime::new();
+ let selector = UpstreamSelectorRuntime::new_with_cooldown(Duration::from_secs(15));
let items = vec![runtime("a"), runtime("b")];
selector.mark_cooldown_until("responses", "a", Instant::now() + Duration::from_secs(10));
@@ -58,3 +58,15 @@ fn clear_cooldown_restores_base_order() {
assert_eq!(order, vec![0, 1]);
}
+
+#[test]
+fn zero_retryable_failure_cooldown_disables_cross_request_cooling() {
+ let selector = UpstreamSelectorRuntime::new_with_cooldown(Duration::ZERO);
+ let items = vec![runtime("a"), runtime("b")];
+
+ selector.mark_retryable_failure("responses", "a");
+
+ let order = selector.order_group(UpstreamStrategy::PriorityFillFirst, "responses", &items, 0);
+
+ assert_eq!(order, vec![0, 1]);
+}
From 054b591d0b73d050ce22976ec9bcec308d91739b Mon Sep 17 00:00:00 2001
From: mxyhi
Date: Sun, 8 Mar 2026 10:19:47 +0800
Subject: [PATCH 2/7] feat(config): expose retry cooldown setting
---
README.md | 4 ++-
README.zh-CN.md | 4 ++-
messages/en.json | 3 ++
messages/zh.json | 3 ++
src-tauri/tauri.conf.dev.json | 2 +-
src/features/config/cards/proxy-core-card.tsx | 17 +++++++++++
src/features/config/form.test.ts | 17 +++++++++++
src/features/config/form.ts | 30 +++++++++++++++++++
src/features/config/types.ts | 2 ++
9 files changed, 79 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index 6839e0a..acc087d 100644
--- a/README.md
+++ b/README.md
@@ -100,6 +100,7 @@ Notes:
| `app_proxy_url` | `null` | Proxy for app updater & as placeholder for upstreams (`"$app_proxy_url"`). Supports `http/https/socks5/socks5h`. |
| `log_level` | `silent` | `silent|error|warn|info|debug|trace`; debug/trace log request headers (auth redacted) and small bodies (≤64KiB). Release builds force `silent`. |
| `max_request_body_bytes` | `20971520` (20 MiB) | 0 = fallback to default. Protects inbound body size. |
+| `retryable_failure_cooldown_secs` | `15` | Cooldown window after retryable failures that should temporarily sideline an upstream. `0` disables cooldown. Saving config resets current cooldown state. |
| `tray_token_rate.enabled` | `true` | macOS tray live rate; harmless elsewhere. |
| `tray_token_rate.format` | `split` | `combined` (`total`), `split` (`↑in ↓out`), `both` (`total | ↑in ↓out`). |
| `upstream_strategy` | `priority_fill_first` | `priority_fill_first` (default) keeps trying the highest-priority group in list order; `priority_round_robin` rotates within each priority group. |
@@ -147,7 +148,8 @@ Notes:
## Load balancing & retries
- Priorities: higher `priority` groups first; inside a group use list order (fill-first) or round-robin (if `priority_round_robin`).
-- Retryable conditions: network timeout/connect errors, or status 400/403/429/307/5xx **except** 504/524. Retries stay within the same provider's priority groups.
+- Retryable conditions: network timeout/connect errors, or status 400/401/403/404/408/422/429/307/5xx (including 504/524). Retries stay within the same provider's priority groups.
+- Cooldown conditions: `401/403/408/429/5xx` will temporarily move the failed upstream behind ready peers for `retryable_failure_cooldown_secs` (default `15`); `400/404/422/307` stay retryable but do not trigger cross-request cooldown.
- `/v1/messages` only: after the chosen native provider is exhausted (retryable errors), the proxy can fall back to the other native provider (`anthropic` ↔ `kiro`) if it is configured.
## Observability
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 893afdb..adadd00 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -100,6 +100,7 @@ pnpm exec tsc --noEmit
| `app_proxy_url` | `null` | 应用更新 & 上游可复用的代理;支持 `http/https/socks5/socks5h`;可在 upstream `proxy_url` 用 `"$app_proxy_url"` 占位 |
| `log_level` | `silent` | `silent|error|warn|info|debug|trace`;debug/trace 会记录请求头(鉴权打码)与小体积请求体(≤64KiB);release 强制 `silent` |
| `max_request_body_bytes` | `20971520` (20 MiB) | 0 表示回落到默认;保护入站体积 |
+| `retryable_failure_cooldown_secs` | `15` | 对适合短时降级的可重试失败施加冷却窗口;`0` 表示关闭冷却。保存配置后会重置当前冷却状态 |
| `tray_token_rate.enabled` | `true` | macOS 托盘实时速率;其他平台无害 |
| `tray_token_rate.format` | `split` | `combined`(总数) / `split`(↑入 ↓出) / `both`(总数 | ↑入 ↓出) |
| `upstream_strategy` | `priority_fill_first` | `priority_fill_first` 默认先填满高优先级;`priority_round_robin` 在同组内轮询 |
@@ -147,7 +148,8 @@ pnpm exec tsc --noEmit
## 负载均衡与重试
- 优先级:高优先级组先尝试;组内按列表顺序(fill-first)或轮询(round-robin)
-- 可重试条件:网络超时/连接错误,或状态码 400/403/429/307/5xx(排除 504/524);重试只在同一 provider 的优先级组内进行
+- 可重试条件:网络超时/连接错误,或状态码 400/401/403/404/408/422/429/307/5xx(包含 504/524);重试只在同一 provider 的优先级组内进行
+- 冷却条件:`401/403/408/429/5xx` 会让失败 upstream 在 `retryable_failure_cooldown_secs`(默认 `15`)内被暂时后置;`400/404/422/307` 仍可重试,但不会触发跨请求冷却
- 仅 `/v1/messages`:当命中的 native provider(`anthropic`/`kiro`)被耗尽(仍是可重试错误)时,若另一个 native provider 已配置,会自动 fallback(Anthropic ↔ Kiro)
## 可观测性
diff --git a/messages/en.json b/messages/en.json
index 5674646..e0412f2 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -54,6 +54,8 @@
"proxy_core_app_proxy_url_help": "Used by the app and optional upstream proxy reuse. Supports http(s)://, socks5://, socks5h://. Upstreams can use {placeholder} to reference this proxy. Leave empty to skip proxy.",
"proxy_core_kiro_preferred_endpoint_label": "Kiro preferred endpoint",
"proxy_core_kiro_preferred_endpoint_help": "Sets the default Kiro endpoint (IDE = CodeWhisperer, CLI = Amazon Q).",
+ "proxy_core_retryable_failure_cooldown_secs_label": "Retryable failure cooldown (seconds)",
+ "proxy_core_retryable_failure_cooldown_secs_help": "Default is 15. Applies cross-request cooldown to 401/403/408/429/5xx; saving config resets the current cooldown state. Use 0 to disable cooldown.",
"proxy_core_format_conversion_title": "Format Conversion",
"proxy_core_format_conversion_desc": "Auto-convert OpenAI/Anthropic/Gemini formats when the preferred provider is missing.",
"proxy_core_format_conversion_aria": "Enable format conversion",
@@ -418,6 +420,7 @@
"logs_detail_response": "Error response",
"error_host_required": "Host is required.",
"error_port_range": "Port must be between 1 and 65535.",
+ "error_retryable_failure_cooldown_secs_integer": "Retryable failure cooldown must be a non-negative integer.",
"error_upstream_at_least_one": "At least one upstream is required.",
"error_upstream_at_least_one_enabled": "At least one enabled upstream is required.",
"error_upstream_id_required": "Upstream id is required.",
diff --git a/messages/zh.json b/messages/zh.json
index 8c78423..00af02b 100644
--- a/messages/zh.json
+++ b/messages/zh.json
@@ -54,6 +54,8 @@
"proxy_core_app_proxy_url_help": "用于应用使用与上游代理复用。支持 http(s)://、socks5://、socks5h://。上游可填写 {placeholder} 引用应用代理;留空不使用代理。",
"proxy_core_kiro_preferred_endpoint_label": "Kiro 默认端点",
"proxy_core_kiro_preferred_endpoint_help": "设置默认 Kiro 端点(IDE=CodeWhisperer,CLI=Amazon Q)。",
+ "proxy_core_retryable_failure_cooldown_secs_label": "可重试失败冷却(秒)",
+ "proxy_core_retryable_failure_cooldown_secs_help": "默认 15。对 401/403/408/429/5xx 触发跨请求冷却;保存配置后会重置当前冷却状态。填 0 可关闭冷却。",
"proxy_core_format_conversion_title": "格式转换",
"proxy_core_format_conversion_desc": "当首选提供商缺失时,自动转换 OpenAI/Anthropic/Gemini 格式。",
"proxy_core_format_conversion_aria": "启用格式转换",
@@ -419,6 +421,7 @@
"logs_detail_response": "错误响应",
"error_host_required": "Host 为必填。",
"error_port_range": "端口必须在 1 到 65535 之间。",
+ "error_retryable_failure_cooldown_secs_integer": "可重试失败冷却必须是大于等于 0 的整数。",
"error_upstream_at_least_one": "至少需要一个上游。",
"error_upstream_at_least_one_enabled": "至少需要一个启用的上游。",
"error_upstream_id_required": "上游 ID 为必填。",
diff --git a/src-tauri/tauri.conf.dev.json b/src-tauri/tauri.conf.dev.json
index 9a5e0fc..89a7678 100644
--- a/src-tauri/tauri.conf.dev.json
+++ b/src-tauri/tauri.conf.dev.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Token Proxy (dev)",
- "version": "0.1.36",
+ "version": "0.1.48",
"identifier": "com.mxyhi.token-proxy.dev",
"build": {
"beforeDevCommand": "pnpm dev",
diff --git a/src/features/config/cards/proxy-core-card.tsx b/src/features/config/cards/proxy-core-card.tsx
index 2c26e8c..1909c9c 100644
--- a/src/features/config/cards/proxy-core-card.tsx
+++ b/src/features/config/cards/proxy-core-card.tsx
@@ -118,6 +118,23 @@ function ProxyCoreFields({
{m.proxy_core_kiro_preferred_endpoint_help()}
+
+
+
+ onChange({ retryableFailureCooldownSecs: event.target.value })
+ }
+ placeholder="15"
+ inputMode="numeric"
+ />
+
+ {m.proxy_core_retryable_failure_cooldown_secs_help()}
+
+
>
);
}
diff --git a/src/features/config/form.test.ts b/src/features/config/form.test.ts
index 47e309f..20c3de8 100644
--- a/src/features/config/form.test.ts
+++ b/src/features/config/form.test.ts
@@ -20,6 +20,13 @@ describe("config/form", () => {
expect(validate({ ...EMPTY_FORM, port: "9208" }).valid).toBe(true);
});
+ it("validates retryable failure cooldown as non-negative integer", () => {
+ expect(validate({ ...EMPTY_FORM, retryableFailureCooldownSecs: "-1" }).valid).toBe(false);
+ expect(validate({ ...EMPTY_FORM, retryableFailureCooldownSecs: "" }).valid).toBe(false);
+ expect(validate({ ...EMPTY_FORM, retryableFailureCooldownSecs: "0" }).valid).toBe(true);
+ expect(validate({ ...EMPTY_FORM, retryableFailureCooldownSecs: "15" }).valid).toBe(true);
+ });
+
it("requires upstream id for enabled upstreams", () => {
const upstream = createEmptyUpstream();
const result = validate({ ...EMPTY_FORM, upstreams: [upstream] });
@@ -82,6 +89,7 @@ describe("config/form", () => {
expect(payload.host).toBe("127.0.0.1");
expect(payload.local_api_key).toBeNull();
+ expect(payload.retryable_failure_cooldown_secs).toBe(15);
expect(payload.upstreams[0]?.id).toBe("upstream-1");
expect(payload.upstreams[0]?.providers).toEqual(["openai", "openai-response"]);
expect(payload.upstreams[0]?.base_url).toBe("https://example.com");
@@ -89,4 +97,13 @@ describe("config/form", () => {
// openai_chat 对 openai 是 native 格式,应被清理;unknown provider 也应被丢弃。
expect(payload.upstreams[0]?.convert_from_map).toBeUndefined();
});
+
+ it("serializes retryable failure cooldown seconds", () => {
+ const payload = toPayload({
+ ...EMPTY_FORM,
+ retryableFailureCooldownSecs: "30",
+ });
+
+ expect(payload.retryable_failure_cooldown_secs).toBe(30);
+ });
});
diff --git a/src/features/config/form.ts b/src/features/config/form.ts
index a30bcab..126bb36 100644
--- a/src/features/config/form.ts
+++ b/src/features/config/form.ts
@@ -18,6 +18,7 @@ const DEFAULT_TRAY_TOKEN_RATE: TrayTokenRateConfig = {
};
const INTEGER_PATTERN = /^-?\d+$/;
+const NON_NEGATIVE_INTEGER_PATTERN = /^\d+$/;
let modelMappingCounter = 0;
const TRAY_TOKEN_RATE_FORMAT_VALUES: ReadonlySet = new Set(
@@ -58,6 +59,7 @@ const KNOWN_CONFIG_KEYS: ReadonlySet = new Set([
"antigravity_process_names",
"antigravity_user_agent",
"log_level",
+ "retryable_failure_cooldown_secs",
"tray_token_rate",
"upstream_strategy",
"upstreams",
@@ -74,6 +76,7 @@ export const EMPTY_FORM: ConfigForm = {
antigravityProcessNames: "",
antigravityUserAgent: "",
logLevel: "silent",
+ retryableFailureCooldownSecs: "15",
trayTokenRate: { ...DEFAULT_TRAY_TOKEN_RATE },
upstreamStrategy: "priority_fill_first",
upstreams: [],
@@ -143,6 +146,7 @@ export function toForm(config: ProxyConfigFile): ConfigForm {
antigravityProcessNames: joinListInput(config.antigravity_process_names),
antigravityUserAgent: config.antigravity_user_agent ?? "",
logLevel: config.log_level ?? "silent",
+ retryableFailureCooldownSecs: String(config.retryable_failure_cooldown_secs ?? 15),
trayTokenRate: normalizeTrayTokenRate(config.tray_token_rate),
upstreamStrategy: config.upstream_strategy,
upstreams: config.upstreams.map((upstream) => ({
@@ -183,6 +187,9 @@ export function toPayload(form: ConfigForm): ProxyConfigFile {
? form.antigravityUserAgent.trim()
: null,
log_level: form.logLevel,
+ retryable_failure_cooldown_secs: parseRetryableFailureCooldownSecs(
+ form.retryableFailureCooldownSecs,
+ ),
tray_token_rate: form.trayTokenRate,
upstream_strategy: form.upstreamStrategy,
upstreams: form.upstreams.map((upstream) => {
@@ -226,6 +233,12 @@ export function validate(form: ConfigForm) {
if (form.appProxyUrl.trim() && !isValidProxyUrl(form.appProxyUrl.trim())) {
return { valid: false, message: m.error_app_proxy_url_invalid() };
}
+ if (!isValidRetryableFailureCooldownSecs(form.retryableFailureCooldownSecs)) {
+ return {
+ valid: false,
+ message: m.error_retryable_failure_cooldown_secs_integer(),
+ };
+ }
const ids = new Set();
for (const upstream of form.upstreams) {
@@ -509,3 +522,20 @@ function parseOptionalInt(value: string) {
const number = Number.parseInt(trimmed, 10);
return Number.isFinite(number) ? number : null;
}
+
+function isValidRetryableFailureCooldownSecs(value: string) {
+ const trimmed = value.trim();
+ if (!trimmed) {
+ return false;
+ }
+ return NON_NEGATIVE_INTEGER_PATTERN.test(trimmed);
+}
+
+function parseRetryableFailureCooldownSecs(value: string) {
+ const trimmed = value.trim();
+ if (!NON_NEGATIVE_INTEGER_PATTERN.test(trimmed)) {
+ return 15;
+ }
+ const number = Number.parseInt(trimmed, 10);
+ return Number.isFinite(number) ? number : 15;
+}
diff --git a/src/features/config/types.ts b/src/features/config/types.ts
index 84cd1bc..9f1be09 100644
--- a/src/features/config/types.ts
+++ b/src/features/config/types.ts
@@ -84,6 +84,7 @@ export type ProxyConfigFileBase = {
antigravity_process_names?: string[];
antigravity_user_agent?: string | null;
log_level?: LogLevel;
+ retryable_failure_cooldown_secs?: number;
tray_token_rate: TrayTokenRateConfig;
upstream_strategy: UpstreamStrategy;
upstreams: UpstreamConfig[];
@@ -151,6 +152,7 @@ export type ConfigForm = {
antigravityProcessNames: string;
antigravityUserAgent: string;
logLevel: LogLevel;
+ retryableFailureCooldownSecs: string;
trayTokenRate: TrayTokenRateConfig;
upstreamStrategy: UpstreamStrategy;
upstreams: UpstreamForm[];
From 1ed68b3ea12d9ee73947c02a79e943c5680348d3 Mon Sep 17 00:00:00 2001
From: mxyhi
Date: Sun, 8 Mar 2026 10:19:59 +0800
Subject: [PATCH 3/7] refactor(config): improve upstreams table readability
---
.../config/cards/upstreams/constants.test.ts | 21 +++
.../config/cards/upstreams/constants.ts | 16 +--
.../config/cards/upstreams/table.test.tsx | 109 +++++++++++++++
src/features/config/cards/upstreams/table.tsx | 128 +++++++++++-------
4 files changed, 216 insertions(+), 58 deletions(-)
create mode 100644 src/features/config/cards/upstreams/constants.test.ts
create mode 100644 src/features/config/cards/upstreams/table.test.tsx
diff --git a/src/features/config/cards/upstreams/constants.test.ts b/src/features/config/cards/upstreams/constants.test.ts
new file mode 100644
index 0000000..68e6b8e
--- /dev/null
+++ b/src/features/config/cards/upstreams/constants.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from "vitest";
+
+import { UPSTREAM_COLUMNS } from "@/features/config/cards/upstreams/constants";
+
+describe("upstreams/constants", () => {
+ it("adjusts id, provider, account, and priority column widths", () => {
+ const idColumn = UPSTREAM_COLUMNS.find((column) => column.id === "id");
+ const providerColumn = UPSTREAM_COLUMNS.find((column) => column.id === "provider");
+ const accountColumn = UPSTREAM_COLUMNS.find((column) => column.id === "account");
+ const priorityColumn = UPSTREAM_COLUMNS.find((column) => column.id === "priority");
+
+ expect(idColumn?.headerClassName).toBe("w-[12rem]");
+ expect(idColumn?.cellClassName).toBe("w-[12rem] max-w-[12rem]");
+ expect(providerColumn?.headerClassName).toBe("w-[10rem]");
+ expect(providerColumn?.cellClassName).toBe("w-[10rem] max-w-[10rem]");
+ expect(accountColumn?.headerClassName).toBe("w-[7.5rem]");
+ expect(accountColumn?.cellClassName).toBe("w-[7.5rem] max-w-[7.5rem]");
+ expect(priorityColumn?.headerClassName).toBe("w-[6rem]");
+ expect(priorityColumn?.cellClassName).toBe("w-[6rem]");
+ });
+});
diff --git a/src/features/config/cards/upstreams/constants.ts b/src/features/config/cards/upstreams/constants.ts
index 69811d1..839e124 100644
--- a/src/features/config/cards/upstreams/constants.ts
+++ b/src/features/config/cards/upstreams/constants.ts
@@ -6,22 +6,22 @@ export const UPSTREAM_COLUMNS: readonly UpstreamColumnDefinition[] = [
id: "id",
label: () => m.upstreams_column_id(),
defaultVisible: true,
- headerClassName: "w-[14rem]",
- cellClassName: "w-[14rem] max-w-[14rem]",
+ headerClassName: "w-[12rem]",
+ cellClassName: "w-[12rem] max-w-[12rem]",
},
{
id: "provider",
label: () => m.upstreams_column_provider(),
defaultVisible: true,
- headerClassName: "w-[12rem]",
- cellClassName: "w-[12rem] max-w-[12rem]",
+ headerClassName: "w-[10rem]",
+ cellClassName: "w-[10rem] max-w-[10rem]",
},
{
id: "account",
label: () => m.upstreams_column_account(),
defaultVisible: true,
- headerClassName: "w-[14rem]",
- cellClassName: "w-[14rem] max-w-[14rem]",
+ headerClassName: "w-[7.5rem]",
+ cellClassName: "w-[7.5rem] max-w-[7.5rem]",
},
{ id: "baseUrl", label: () => m.upstreams_column_base_url(), defaultVisible: false, cellClassName: "min-w-[18rem]" },
{ id: "apiKey", label: () => m.upstreams_column_api_key(), defaultVisible: false, cellClassName: "min-w-[18rem]" },
@@ -30,8 +30,8 @@ export const UPSTREAM_COLUMNS: readonly UpstreamColumnDefinition[] = [
id: "priority",
label: () => m.upstreams_column_priority(),
defaultVisible: true,
- headerClassName: "w-[8rem]",
- cellClassName: "w-[8rem]",
+ headerClassName: "w-[6rem]",
+ cellClassName: "w-[6rem]",
},
{
id: "status",
diff --git a/src/features/config/cards/upstreams/table.test.tsx b/src/features/config/cards/upstreams/table.test.tsx
new file mode 100644
index 0000000..059ec86
--- /dev/null
+++ b/src/features/config/cards/upstreams/table.test.tsx
@@ -0,0 +1,109 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { UPSTREAM_COLUMNS } from "@/features/config/cards/upstreams/constants";
+import { UpstreamsTable } from "@/features/config/cards/upstreams/table";
+import type { UpstreamForm } from "@/features/config/types";
+
+const LONG_ID = "codex-account-with-a-very-long-upstream-id-for-tooltip";
+const LONG_EMAIL = "very.long.codex.account.email.for.tooltip@example.com";
+
+afterEach(() => {
+ cleanup();
+});
+
+function buildUpstream(): UpstreamForm {
+ return {
+ id: LONG_ID,
+ providers: ["codex"],
+ baseUrl: "https://api.example.com/v1",
+ apiKey: "",
+ filterPromptCacheRetention: false,
+ filterSafetyIdentifier: false,
+ kiroAccountId: "",
+ codexAccountId: "codex-1.json",
+ antigravityAccountId: "",
+ preferredEndpoint: "",
+ proxyUrl: "",
+ priority: "10",
+ enabled: true,
+ modelMappings: [],
+ convertFromMap: {},
+ overrides: { header: [] },
+ };
+}
+
+describe("upstreams/table", () => {
+ it("shows tooltip for truncated id cells on hover", async () => {
+ const user = userEvent.setup();
+
+ render(
+ undefined}
+ onCopy={() => undefined}
+ onToggleEnabled={() => undefined}
+ onDelete={() => undefined}
+ />
+ );
+
+ const idCell = screen.getByText(LONG_ID);
+ await user.hover(idCell);
+ expect(await screen.findByRole("tooltip")).toHaveTextContent(LONG_ID);
+ });
+
+ it("shows tooltip for truncated account cells on hover", async () => {
+ const user = userEvent.setup();
+
+ render(
+ undefined}
+ onCopy={() => undefined}
+ onToggleEnabled={() => undefined}
+ onDelete={() => undefined}
+ />
+ );
+
+ const accountCell = screen.getByText(LONG_EMAIL);
+ await user.hover(accountCell);
+ expect(await screen.findByRole("tooltip")).toHaveTextContent(LONG_EMAIL);
+ });
+});
diff --git a/src/features/config/cards/upstreams/table.tsx b/src/features/config/cards/upstreams/table.tsx
index e79d54e..4129f79 100644
--- a/src/features/config/cards/upstreams/table.tsx
+++ b/src/features/config/cards/upstreams/table.tsx
@@ -1,3 +1,6 @@
+import type { ReactElement } from "react";
+
+import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { Ban, Check, Columns3, Copy, Eye, EyeOff, Pencil, Trash2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
@@ -10,6 +13,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
+import { TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
getUpstreamLabel,
toMaskedApiKey,
@@ -36,11 +40,38 @@ type UpstreamsToolbarProps = {
const UPSTREAM_STRATEGY_VALUES: ReadonlySet = new Set(
UPSTREAM_STRATEGIES.map((strategy) => strategy.value)
);
+const CELL_PLACEHOLDER = "—";
+const TOOLTIP_CONTENT_CLASS = "max-w-[560px] whitespace-pre-wrap break-words";
function toUpstreamStrategy(value: string): UpstreamStrategy | null {
return UPSTREAM_STRATEGY_VALUES.has(value) ? (value as UpstreamStrategy) : null;
}
+type CellTooltipProps = {
+ content: string;
+ disabled?: boolean;
+ children: ReactElement;
+};
+
+function shouldDisableTooltip(content: string) {
+ const trimmed = content.trim();
+ return trimmed.length === 0 || trimmed === CELL_PLACEHOLDER;
+}
+
+function CellTooltip({ content, disabled, children }: CellTooltipProps) {
+ if (disabled || shouldDisableTooltip(content)) {
+ return children;
+ }
+ return (
+
+ {children}
+
+ {content}
+
+
+ );
+}
+
export function UpstreamsToolbar({
apiKeyVisible,
showApiKeys,
@@ -141,10 +172,13 @@ type CodexAccountMap = Map;
type AntigravityAccountMap = Map;
function renderTextCell(value: string, placeholder: string) {
- return value.trim() ? (
- {value}
- ) : (
- {placeholder}
+ const trimmed = value.trim();
+ return (
+
+
+ {trimmed || placeholder}
+
+
);
}
@@ -167,58 +201,50 @@ function renderAccountCell(
if (provider === "kiro") {
const accountId = upstream.kiroAccountId.trim();
if (!accountId) {
- return {m.kiro_account_unset()};
+ return renderTextCell("", m.kiro_account_unset());
}
const account = kiroAccounts.get(accountId);
if (!account) {
- return {m.kiro_account_missing()};
+ return renderTextCell("", m.kiro_account_missing());
}
- return {account.account_id};
+ return renderTextCell(account.account_id, m.kiro_account_unset());
}
if (provider === "codex") {
const accountId = upstream.codexAccountId.trim();
if (!accountId) {
- return {m.codex_account_unset()};
+ return renderTextCell("", m.codex_account_unset());
}
const account = codexAccounts.get(accountId);
if (!account) {
- return {m.codex_account_missing()};
+ return renderTextCell("", m.codex_account_missing());
}
const label = account.email?.trim() ? account.email : account.account_id;
- return {label};
+ return renderTextCell(label, m.codex_account_unset());
}
if (provider === "antigravity") {
const accountId = upstream.antigravityAccountId.trim();
if (!accountId) {
- return {m.antigravity_account_unset()};
+ return renderTextCell("", m.antigravity_account_unset());
}
const account = antigravityAccounts.get(accountId);
if (!account) {
- return {m.antigravity_account_missing()};
+ return renderTextCell("", m.antigravity_account_missing());
}
const label = account.email?.trim() ? account.email : account.account_id;
- return {label};
+ return renderTextCell(label, m.antigravity_account_unset());
}
- return —;
+ return renderTextCell("", CELL_PLACEHOLDER);
}
function renderApiKeyCell(upstream: UpstreamForm, showApiKeys: boolean) {
const value = showApiKeys ? upstream.apiKey : toMaskedApiKey(upstream.apiKey);
- return value.trim() ? (
- {value}
- ) : (
- {m.common_optional()}
- );
+ return renderTextCell(value, m.common_optional());
}
function renderProxyUrlCell(upstream: UpstreamForm, showApiKeys: boolean) {
const rawValue = upstream.proxyUrl;
const value = showApiKeys ? rawValue : toMaskedProxyUrl(rawValue);
- return value.trim() ? (
- {value}
- ) : (
- {m.upstreams_proxy_direct()}
- );
+ return renderTextCell(value, m.upstreams_proxy_direct());
}
function renderUpstreamCell(
@@ -364,7 +390,7 @@ function UpstreamsTableRow({
key={column.id}
className={["px-3 py-2 align-top", column.cellClassName].filter(Boolean).join(" ")}
>
-
+
{renderUpstreamCell(
column.id,
upstream,
@@ -449,30 +475,32 @@ export function UpstreamsTable({
}: UpstreamsTableProps) {
const sortedUpstreams = sortUpstreamsByPriority(upstreams);
return (
-
-
-
-
- {sortedUpstreams.map((entry, displayIndex) => (
-
- ))}
-
-
-
+
+
+
+
+
+ {sortedUpstreams.map((entry, displayIndex) => (
+
+ ))}
+
+
+
+
);
}
From 4993945b229ba6e6bd95f59b295bec0ea52debc1 Mon Sep 17 00:00:00 2001
From: mxyhi
Date: Sun, 8 Mar 2026 10:20:16 +0800
Subject: [PATCH 4/7] refactor(config): auto-save config changes
---
src/features/config/AppView.test.tsx | 89 +++++++++++
src/features/config/AppView.tsx | 20 +--
src/features/config/ConfigScreen.test.tsx | 176 +++++++++++++++++++++
src/features/config/ConfigScreen.tsx | 30 +++-
src/features/config/config-screen-state.ts | 16 +-
5 files changed, 308 insertions(+), 23 deletions(-)
create mode 100644 src/features/config/AppView.test.tsx
create mode 100644 src/features/config/ConfigScreen.test.tsx
diff --git a/src/features/config/AppView.test.tsx b/src/features/config/AppView.test.tsx
new file mode 100644
index 0000000..c53a847
--- /dev/null
+++ b/src/features/config/AppView.test.tsx
@@ -0,0 +1,89 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { AppView } from "@/features/config/AppView";
+import { EMPTY_FORM } from "@/features/config/form";
+import type { ProxyServiceStatus } from "@/features/config/types";
+import { m } from "@/paraglide/messages.js";
+
+vi.mock("@/components/app-sidebar", () => ({
+ AppSidebar: () => ,
+}));
+
+vi.mock("@/components/site-header", () => ({
+ SiteHeader: ({ title }: { title: string }) => {title}
,
+}));
+
+vi.mock("@/features/config/cards", () => ({
+ ClientSetupCard: () => ,
+ ConfigFileCard: () => ,
+ AutoStartCard: () => ,
+ ProjectLinksCard: () => ,
+ ProxyCoreCard: () => ,
+ TrayTokenRateCard: () => ,
+ UpdateCard: () => ,
+ UpstreamsCard: () => ,
+ ValidationCard: () => ,
+}));
+
+vi.mock("@/features/dashboard/DashboardPanel", () => ({
+ DashboardPanel: () => ,
+}));
+
+vi.mock("@/features/logs/LogsPanel", () => ({
+ LogsPanel: () => ,
+}));
+
+vi.mock("@/features/providers/ProvidersPanel", () => ({
+ ProvidersPanel: () => ,
+}));
+
+const IDLE_PROXY_STATUS: ProxyServiceStatus = {
+ state: "stopped",
+ addr: null,
+ last_error: null,
+};
+
+describe("config/AppView", () => {
+ it("removes the manual save button from the config toolbar", () => {
+ render(
+ undefined}
+ onToggleUpstreamKeys={() => undefined}
+ onFormChange={() => undefined}
+ onStrategyChange={() => undefined}
+ onAutoStartChange={() => undefined}
+ onAddUpstream={() => undefined}
+ onRemoveUpstream={() => undefined}
+ onChangeUpstream={() => undefined}
+ onReload={() => undefined}
+ onProxyServiceRefresh={() => undefined}
+ onProxyServiceStart={() => undefined}
+ onProxyServiceStop={() => undefined}
+ onProxyServiceRestart={() => undefined}
+ onProxyServiceReload={() => undefined}
+ />
+ );
+
+ expect(screen.getByRole("button", { name: m.common_refresh() })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: m.common_save() })).not.toBeInTheDocument();
+ });
+});
diff --git a/src/features/config/AppView.tsx b/src/features/config/AppView.tsx
index 0adcc93..6d299fe 100644
--- a/src/features/config/AppView.tsx
+++ b/src/features/config/AppView.tsx
@@ -1,4 +1,4 @@
-import { AlertCircle, Loader2, RefreshCw } from "lucide-react";
+import { AlertCircle, RefreshCw } from "lucide-react";
import { useMemo, type CSSProperties } from "react";
import { AppSidebar } from "@/components/app-sidebar";
@@ -63,7 +63,6 @@ type AppViewProps = {
proxyServiceMessage: string;
status: "idle" | "loading" | "saving" | "saved" | "error";
statusMessage: string;
- canSave: boolean;
isDirty: boolean;
validation: { valid: boolean; message: string };
onToggleLocalKey: () => void;
@@ -77,7 +76,6 @@ type AppViewProps = {
index: number,
patch: Partial
) => void;
- onSave: () => void;
onReload: () => void;
onProxyServiceRefresh: () => void;
onProxyServiceStart: () => void;
@@ -89,23 +87,18 @@ type AppViewProps = {
type ConfigToolbarProps = {
section: ConfigSection;
status: AppViewProps["status"];
- canSave: boolean;
isDirty: boolean;
onReload: () => void;
- onSave: () => void;
};
function ConfigToolbar({
section,
status,
- canSave,
isDirty,
onReload,
- onSave,
}: ConfigToolbarProps) {
const isLoading = status === "loading";
- const isSaving = status === "saving";
- const canReload = !isLoading && !isSaving;
+ const canReload = status !== "saving" && !isLoading;
return (
{m.common_refresh()}
)}
-
);
@@ -287,10 +273,8 @@ function ConfigSectionContent({
({
+ setAppProxyUrlMock: vi.fn<(value: string) => void>(),
+}));
+
+vi.mock("@/features/update/updater", () => ({
+ useUpdater: () => ({
+ state: {
+ status: "idle",
+ statusMessage: "",
+ lastCheckedAt: "",
+ updateInfo: null,
+ updateHandle: null,
+ downloadState: { downloaded: 0, total: 0 },
+ lastCheckSource: null,
+ appProxyUrl: "",
+ appProxyUrlReady: true,
+ },
+ actions: {
+ setAppProxyUrl: setAppProxyUrlMock,
+ checkForUpdate: async () => undefined,
+ downloadAndInstall: async () => undefined,
+ relaunchApp: async () => undefined,
+ },
+ }),
+}));
+
+vi.mock("@/features/config/AppView", () => ({
+ AppView: ({
+ form,
+ isDirty,
+ status,
+ statusMessage,
+ onFormChange,
+ }: {
+ form: ConfigForm;
+ isDirty: boolean;
+ status: "idle" | "loading" | "saving" | "saved" | "error";
+ statusMessage: string;
+ onFormChange: (patch: Partial) => void;
+ }) => (
+
+
+
onFormChange({ host: event.target.value })}
+ />
+
{status}
+
{String(isDirty)}
+
{statusMessage}
+
+ ),
+}));
+
+const PROXY_STATUS: ProxyServiceStatus = {
+ state: "running",
+ addr: "127.0.0.1:9208",
+ last_error: null,
+};
+
+describe("config/ConfigScreen auto save", () => {
+ beforeEach(() => {
+ setAppProxyUrlMock.mockReset();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.mocked(invoke).mockReset();
+ });
+
+ async function waitForAutoSaveWindow() {
+ await new Promise((resolve) => {
+ window.setTimeout(resolve, 1200);
+ });
+ }
+
+ it("auto saves only after edits settle", async () => {
+ const invokeMock = vi.mocked(invoke);
+ const config = { ...toPayload(EMPTY_FORM), host: "10.0.0.1" };
+
+ invokeMock.mockImplementation(async (command, args) => {
+ if (command === "read_proxy_config") {
+ return { path: "/tmp/config.json", config };
+ }
+ if (command === "proxy_status") {
+ return PROXY_STATUS;
+ }
+ if (command === "write_proxy_config") {
+ return { ...PROXY_STATUS, args };
+ }
+ throw new Error(`unexpected command: ${command}`);
+ });
+
+ render(
+
+
+
+ );
+
+ const hostInput = screen.getByLabelText("host");
+ await waitFor(() => {
+ expect(hostInput).toHaveValue("10.0.0.1");
+ });
+
+ fireEvent.change(hostInput, { target: { value: "10.0.0.2" } });
+ fireEvent.change(hostInput, { target: { value: "10.0.0.3" } });
+
+ expect(
+ invokeMock.mock.calls.filter(([command]) => command === "write_proxy_config")
+ ).toHaveLength(0);
+
+ await waitForAutoSaveWindow();
+
+ await waitFor(() => {
+ const writeCalls = invokeMock.mock.calls.filter(([command]) => command === "write_proxy_config");
+ expect(writeCalls).toHaveLength(1);
+ expect(writeCalls[0]?.[1]).toMatchObject({
+ config: expect.objectContaining({ host: "10.0.0.3" }),
+ });
+ });
+ });
+
+ it("does not retry the same failed auto save endlessly", async () => {
+ const invokeMock = vi.mocked(invoke);
+ const config = { ...toPayload(EMPTY_FORM), host: "10.0.0.1" };
+
+ invokeMock.mockImplementation(async (command) => {
+ if (command === "read_proxy_config") {
+ return { path: "/tmp/config.json", config };
+ }
+ if (command === "proxy_status") {
+ return PROXY_STATUS;
+ }
+ if (command === "write_proxy_config") {
+ throw new Error("disk full");
+ }
+ throw new Error(`unexpected command: ${command}`);
+ });
+
+ render(
+
+
+
+ );
+
+ const hostInput = screen.getByLabelText("host");
+ await waitFor(() => {
+ expect(hostInput).toHaveValue("10.0.0.1");
+ });
+ fireEvent.change(hostInput, { target: { value: "10.0.0.9" } });
+
+ await waitForAutoSaveWindow();
+
+ await waitFor(() => {
+ expect(
+ invokeMock.mock.calls.filter(([command]) => command === "write_proxy_config")
+ ).toHaveLength(1);
+ });
+
+ await waitForAutoSaveWindow();
+
+ expect(
+ invokeMock.mock.calls.filter(([command]) => command === "write_proxy_config")
+ ).toHaveLength(1);
+ expect(screen.getByTestId("status-message")).toHaveTextContent("disk full");
+ });
+});
diff --git a/src/features/config/ConfigScreen.tsx b/src/features/config/ConfigScreen.tsx
index 36ccce2..d24d405 100644
--- a/src/features/config/ConfigScreen.tsx
+++ b/src/features/config/ConfigScreen.tsx
@@ -1,4 +1,4 @@
-import { useEffect } from "react";
+import { useEffect, useRef } from "react";
import { AppView } from "@/features/config/AppView";
import {
@@ -23,6 +23,7 @@ type ProxyServiceState = ReturnType;
type ConfigListActions = ReturnType;
type ConfigActions = ReturnType;
type ProxyServiceActions = ReturnType;
+const CONFIG_AUTO_SAVE_DELAY_MS = 800;
type AppViewArgs = {
activeSectionId: ConfigSectionId;
@@ -60,7 +61,6 @@ function buildAppViewProps({
proxyServiceMessage: proxyService.proxyServiceMessage,
status: state.status,
statusMessage: state.statusMessage,
- canSave: derived.canSave,
isDirty: derived.isDirty,
validation: derived.validation,
onToggleLocalKey: () => state.setShowLocalKey((value) => !value),
@@ -72,7 +72,6 @@ function buildAppViewProps({
onAddUpstream: listActions.addUpstream,
onRemoveUpstream: listActions.removeUpstream,
onChangeUpstream: listActions.updateUpstream,
- onSave: configActions.saveConfig,
onReload: configActions.loadConfig,
onProxyServiceRefresh: proxyActions.refreshProxyStatus,
onProxyServiceStart: proxyActions.startProxy,
@@ -83,6 +82,8 @@ function buildAppViewProps({
}
export function ConfigScreen({ activeSectionId }: ConfigScreenProps) {
+ const lastObservedAutoSaveKeyRef = useRef("");
+ const lastAttemptedAutoSaveKeyRef = useRef("");
const state = useConfigState();
const derived = useConfigDerived(
state.form,
@@ -142,6 +143,29 @@ export function ConfigScreen({ activeSectionId }: ConfigScreenProps) {
void refreshProxyStatus();
}, [refreshProxyStatus]);
+ useEffect(() => {
+ if (derived.autoSaveKey === lastObservedAutoSaveKeyRef.current) {
+ return;
+ }
+ lastObservedAutoSaveKeyRef.current = derived.autoSaveKey;
+ lastAttemptedAutoSaveKeyRef.current = "";
+ }, [derived.autoSaveKey]);
+
+ useEffect(() => {
+ if (!derived.canAutoSave || !derived.autoSaveKey) {
+ return;
+ }
+ if (derived.autoSaveKey === lastAttemptedAutoSaveKeyRef.current) {
+ return;
+ }
+ const timerId = window.setTimeout(() => {
+ // 失败后不应对同一份草稿无限重试;只有用户继续编辑形成新草稿时,才重新进入自动保存。
+ lastAttemptedAutoSaveKeyRef.current = derived.autoSaveKey;
+ void configActions.saveConfig();
+ }, CONFIG_AUTO_SAVE_DELAY_MS);
+ return () => window.clearTimeout(timerId);
+ }, [configActions.saveConfig, derived.autoSaveKey, derived.canAutoSave]);
+
const appViewProps = buildAppViewProps({
activeSectionId,
state,
diff --git a/src/features/config/config-screen-state.ts b/src/features/config/config-screen-state.ts
index b0f9421..00edeae 100644
--- a/src/features/config/config-screen-state.ts
+++ b/src/features/config/config-screen-state.ts
@@ -236,16 +236,28 @@ export function useConfigDerived(
return Array.from(providers);
}, [form.upstreams]);
- const canSave = status !== "saving" && validation.valid && isDirty;
+ const autoSaveKey = useMemo(() => {
+ const segments: string[] = [];
+ if (configDirty && currentPayload) {
+ segments.push(`config:${stableStringify(currentPayload as JsonValue)}`);
+ }
+ if (autoStartDirty) {
+ segments.push(`autostart:${autoStartEnabled ? "enabled" : "disabled"}`);
+ }
+ return segments.join("|");
+ }, [autoStartDirty, autoStartEnabled, configDirty, currentPayload]);
+
+ const canAutoSave = status !== "saving" && validation.valid && isDirty;
return {
validation,
currentPayload,
configDirty,
autoStartDirty,
+ autoSaveKey,
isDirty,
statusBadge,
- canSave,
+ canAutoSave,
providerOptions,
};
}
From 4498d159d86d55e67a784d4e91af067710dce781 Mon Sep 17 00:00:00 2001
From: mxyhi
Date: Sun, 8 Mar 2026 11:16:50 +0800
Subject: [PATCH 5/7] fix(proxy): auto-apply saved config safely
---
README.md | 4 +-
README.zh-CN.md | 4 +-
.../token_proxy_core/src/proxy/config/mod.rs | 18 ++-
.../src/proxy/config/mod.test.rs | 11 ++
crates/token_proxy_core/src/proxy/service.rs | 84 ++++++++++----
.../src/proxy/service.test.rs | 58 ++++++++++
.../src/proxy/upstream_selector.rs | 5 +-
.../src/proxy/upstream_selector.test.rs | 13 +++
messages/en.json | 9 +-
messages/zh.json | 9 +-
src-tauri/src/lib.rs | 42 +++----
src/features/config/AppView.test.tsx | 98 +++++++++++++++-
src/features/config/AppView.tsx | 43 +++++--
src/features/config/ConfigScreen.test.tsx | 107 +++++++++++++++++-
src/features/config/ConfigScreen.tsx | 2 +
src/features/config/config-screen-actions.ts | 4 +-
src/features/config/config-screen-state.ts | 5 +-
17 files changed, 436 insertions(+), 80 deletions(-)
create mode 100644 crates/token_proxy_core/src/proxy/config/mod.test.rs
create mode 100644 crates/token_proxy_core/src/proxy/service.test.rs
diff --git a/README.md b/README.md
index acc087d..f534f2e 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,7 @@ Local AI API gateway for OpenAI / Gemini / Anthropic. Runs on your machine, keep
## Quick start (macOS)
1) Install: move `Token Proxy.app` to `/Applications`. If blocked: `xattr -cr /Applications/Token\ Proxy.app`.
2) Launch the app. The proxy starts automatically.
-3) Open **Config File** tab, edit and save (writes `config.jsonc` in the Tauri config dir). Defaults are usable; just paste your upstream API keys.
+3) Open **Config File** tab, edit and save (writes `config.jsonc` in the Tauri config dir). Defaults are usable; just paste your upstream API keys. Running proxies auto-apply the new config via reload or restart when needed.
4) Call via curl (example with local auth):
```bash
curl -X POST \
@@ -100,7 +100,7 @@ Notes:
| `app_proxy_url` | `null` | Proxy for app updater & as placeholder for upstreams (`"$app_proxy_url"`). Supports `http/https/socks5/socks5h`. |
| `log_level` | `silent` | `silent|error|warn|info|debug|trace`; debug/trace log request headers (auth redacted) and small bodies (≤64KiB). Release builds force `silent`. |
| `max_request_body_bytes` | `20971520` (20 MiB) | 0 = fallback to default. Protects inbound body size. |
-| `retryable_failure_cooldown_secs` | `15` | Cooldown window after retryable failures that should temporarily sideline an upstream. `0` disables cooldown. Saving config resets current cooldown state. |
+| `retryable_failure_cooldown_secs` | `15` | Cooldown window after retryable failures that should temporarily sideline an upstream. `0` disables cooldown. Reloading or restarting the running proxy resets current cooldown state. |
| `tray_token_rate.enabled` | `true` | macOS tray live rate; harmless elsewhere. |
| `tray_token_rate.format` | `split` | `combined` (`total`), `split` (`↑in ↓out`), `both` (`total | ↑in ↓out`). |
| `upstream_strategy` | `priority_fill_first` | `priority_fill_first` (default) keeps trying the highest-priority group in list order; `priority_round_robin` rotates within each priority group. |
diff --git a/README.zh-CN.md b/README.zh-CN.md
index adadd00..22999c7 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -26,7 +26,7 @@
## 快速上手(macOS)
1) 安装:把 `Token Proxy.app` 放到 `/Applications`。若被拦截,执行 `xattr -cr /Applications/Token\ Proxy.app`。
2) 启动应用,代理会自动运行。
-3) 打开 **Config File** 标签,编辑并保存(写入 Tauri 配置目录下的 `config.jsonc`)。默认配置可用,只需填入上游 API Key。
+3) 打开 **Config File** 标签,编辑并保存(写入 Tauri 配置目录下的 `config.jsonc`)。默认配置可用,只需填入上游 API Key。若代理正在运行,保存后会按需自动 reload 或重启。
4) 发请求(本地鉴权示例):
```bash
curl -X POST \
@@ -100,7 +100,7 @@ pnpm exec tsc --noEmit
| `app_proxy_url` | `null` | 应用更新 & 上游可复用的代理;支持 `http/https/socks5/socks5h`;可在 upstream `proxy_url` 用 `"$app_proxy_url"` 占位 |
| `log_level` | `silent` | `silent|error|warn|info|debug|trace`;debug/trace 会记录请求头(鉴权打码)与小体积请求体(≤64KiB);release 强制 `silent` |
| `max_request_body_bytes` | `20971520` (20 MiB) | 0 表示回落到默认;保护入站体积 |
-| `retryable_failure_cooldown_secs` | `15` | 对适合短时降级的可重试失败施加冷却窗口;`0` 表示关闭冷却。保存配置后会重置当前冷却状态 |
+| `retryable_failure_cooldown_secs` | `15` | 对适合短时降级的可重试失败施加冷却窗口;`0` 表示关闭冷却。重载或重启运行中的代理会重置当前冷却状态 |
| `tray_token_rate.enabled` | `true` | macOS 托盘实时速率;其他平台无害 |
| `tray_token_rate.format` | `split` | `combined`(总数) / `split`(↑入 ↓出) / `both`(总数 | ↑入 ↓出) |
| `upstream_strategy` | `priority_fill_first` | `priority_fill_first` 默认先填满高优先级;`priority_round_robin` 在同组内轮询 |
diff --git a/crates/token_proxy_core/src/proxy/config/mod.rs b/crates/token_proxy_core/src/proxy/config/mod.rs
index db4f344..753d600 100644
--- a/crates/token_proxy_core/src/proxy/config/mod.rs
+++ b/crates/token_proxy_core/src/proxy/config/mod.rs
@@ -5,7 +5,7 @@ mod normalize;
mod types;
use crate::paths::TokenProxyPaths;
-use std::time::Duration;
+use std::time::{Duration, Instant};
const DEFAULT_MAX_REQUEST_BODY_BYTES: u64 = 20 * 1024 * 1024;
@@ -68,7 +68,9 @@ fn build_runtime_config(config: ProxyConfigFile) -> Result
local_api_key: config.local_api_key,
log_level,
max_request_body_bytes,
- retryable_failure_cooldown: Duration::from_secs(config.retryable_failure_cooldown_secs),
+ retryable_failure_cooldown: resolve_retryable_failure_cooldown(
+ config.retryable_failure_cooldown_secs,
+ )?,
upstream_strategy: config.upstream_strategy,
upstreams,
kiro_preferred_endpoint: config.kiro_preferred_endpoint,
@@ -76,6 +78,14 @@ fn build_runtime_config(config: ProxyConfigFile) -> Result
})
}
+fn resolve_retryable_failure_cooldown(value: u64) -> Result {
+ let duration = Duration::from_secs(value);
+ if Instant::now().checked_add(duration).is_none() {
+ return Err("retryable_failure_cooldown_secs is too large.".to_string());
+ }
+ Ok(duration)
+}
+
fn resolve_max_request_body_bytes(value: Option) -> usize {
let value = value.unwrap_or(DEFAULT_MAX_REQUEST_BODY_BYTES);
let value = if value == 0 {
@@ -98,3 +108,7 @@ fn normalize_app_proxy_url(value: Option<&str>) -> Result