fix(failover): patch P1-P3 reliability gaps surfaced by team review

- Forwarder buffers non-streaming bodies and primes streaming first
  chunk before signaling success, so body timeouts and SSE first-chunk
  failures route through the circuit breaker instead of being recorded
  as success on response-header arrival
- Atomic enable-failover: switch to P1 before persisting the flag, and
  roll back auto-added queue entries when the switch is rejected
  (e.g. official providers)
- Hot-reload circuit breaker config on per-app proxy config change
  instead of waiting for a proxy restart
- FailoverToggle / FailoverQueueManager / AutoFailoverConfigPanel
  require proxy takeover for the active app; the backend command also
  rejects enabling when takeover is off
- ProviderHealthBadge consumes the backend is_healthy flag instead of
  hardcoding the 5-failure threshold

Cleanup:
- impl From<&AppProxyConfig> for CircuitBreakerConfig and use it from
  the command layer
- Collapse three identical TabsContent blocks into a single map
This commit is contained in:
Jason
2026-05-14 21:35:02 +08:00
parent 940161fb0e
commit b642ef0633
14 changed files with 389 additions and 98 deletions
+28 -17
View File
@@ -86,11 +86,19 @@ pub async fn set_auto_failover_enabled(
"[Failover] Setting auto_failover_enabled: app_type='{app_type}', enabled={enabled}"
);
// 强一致语义:开启故障转移后立即切到队列 P1(并确保队列非空)
//
// 说明:
// - 仅在 enabled=true 时执行“切到 P1”
// - 若队列为空,则尝试把“当前供应商”自动加入队列作为 P1,避免用户在 UI 上陷入死锁(无法先加队列再开启)
// 读取当前配置
let mut config = state
.db
.get_proxy_config_for_app(&app_type)
.await
.map_err(|e| e.to_string())?;
if enabled && !config.enabled {
return Err("需要先启用该应用的代理接管,再开启故障转移".to_string());
}
// 队列为空时把当前供应商自动加入作为 P1,避免用户陷入"必须先加队列才能开启"的死锁
let mut auto_added_provider_id: Option<String> = None;
let p1_provider_id = if enabled {
let mut queue = state
.db
@@ -112,6 +120,7 @@ pub async fn set_auto_failover_enabled(
.db
.add_to_failover_queue(&app_type, &current_id)
.map_err(|e| e.to_string())?;
auto_added_provider_id = Some(current_id);
queue = state
.db
@@ -127,12 +136,20 @@ pub async fn set_auto_failover_enabled(
String::new()
};
// 读取当前配置
let mut config = state
.db
.get_proxy_config_for_app(&app_type)
.await
.map_err(|e| e.to_string())?;
// 开启前先切到 P1。只有切换成功后才写入 auto_failover_enabled=true
// 避免 P1 不可切换(例如 official provider)时留下“开关已开但目标未切”的脏状态。
if enabled {
if let Err(e) = state
.proxy_service
.switch_proxy_target(&app_type, &p1_provider_id)
.await
{
if let Some(provider_id) = auto_added_provider_id {
let _ = state.db.remove_from_failover_queue(&app_type, &provider_id);
}
return Err(e);
}
}
// 更新 auto_failover_enabled 字段
config.auto_failover_enabled = enabled;
@@ -144,13 +161,7 @@ pub async fn set_auto_failover_enabled(
.await
.map_err(|e| e.to_string())?;
// 开启后立即切到 P1:更新 is_current + 本地 settings + Live 备份(接管模式下)
if enabled {
state
.proxy_service
.switch_proxy_target(&app_type, &p1_provider_id)
.await?;
// 发射 provider-switched 事件(让前端刷新当前供应商)
let event_data = serde_json::json!({
"appType": app_type,
+9 -1
View File
@@ -133,9 +133,17 @@ pub async fn update_proxy_config_for_app(
config: AppProxyConfig,
) -> Result<(), String> {
let db = &state.db;
let app_type = config.app_type.clone();
let circuit_config = CircuitBreakerConfig::from(&config);
db.update_proxy_config_for_app(config)
.await
.map_err(|e| e.to_string())
.map_err(|e| e.to_string())?;
state
.proxy_service
.update_circuit_breaker_config_for_app(&app_type, circuit_config)
.await
}
async fn get_default_cost_multiplier_internal(