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
@@ -364,6 +364,7 @@ export function ProviderCard({
{isProxyRunning && isInFailoverQueue && health && (
<ProviderHealthBadge
consecutiveFailures={health.consecutive_failures}
isHealthy={health.is_healthy}
/>
)}
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
interface ProviderHealthBadgeProps {
consecutiveFailures: number;
isHealthy?: boolean;
className?: string;
}
@@ -13,6 +14,7 @@ interface ProviderHealthBadgeProps {
*/
export function ProviderHealthBadge({
consecutiveFailures,
isHealthy,
className,
}: ProviderHealthBadgeProps) {
const { t } = useTranslation();
@@ -29,7 +31,7 @@ export function ProviderHealthBadge({
bgColor: "bg-green-500/10",
textColor: "text-green-600 dark:text-green-400",
};
} else if (consecutiveFailures < 5) {
} else if (isHealthy !== false) {
return {
labelKey: "health.degraded",
labelFallback: "降级",
+17 -8
View File
@@ -10,6 +10,7 @@ import {
useAutoFailoverEnabled,
useSetAutoFailoverEnabled,
} from "@/lib/query/failover";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import type { AppId } from "@/lib/api";
@@ -24,8 +25,11 @@ export function FailoverToggle({ className, activeApp }: FailoverToggleProps) {
const { data: isEnabled = false, isLoading } =
useAutoFailoverEnabled(activeApp);
const setEnabled = useSetAutoFailoverEnabled();
const { takeoverStatus } = useProxyStatus();
const takeoverEnabled = takeoverStatus?.[activeApp] ?? false;
const handleToggle = (checked: boolean) => {
if (checked && !takeoverEnabled) return;
setEnabled.mutate({ appType: activeApp, enabled: checked });
};
@@ -36,15 +40,20 @@ export function FailoverToggle({ className, activeApp }: FailoverToggleProps) {
? "Codex"
: "Gemini";
const tooltipText = isEnabled
? t("failover.tooltip.enabled", {
const tooltipText = !takeoverEnabled
? t("failover.tooltip.takeoverRequired", {
app: appLabel,
defaultValue: `${appLabel} 故障转移已启用\n按队列优先级(P1→P2→...)选择供应商`,
defaultValue: `请先接管 ${appLabel},再启用故障转移`,
})
: t("failover.tooltip.disabled", {
app: appLabel,
defaultValue: `启用 ${appLabel} 故障转移\n将立即切换到队列 P1,并在失败时自动切换到下一个`,
});
: isEnabled
? t("failover.tooltip.enabled", {
app: appLabel,
defaultValue: `${appLabel} 故障转移已启用\n按队列优先级(P1→P2→...)选择供应商`,
})
: t("failover.tooltip.disabled", {
app: appLabel,
defaultValue: `启用 ${appLabel} 故障转移\n将立即切换到队列 P1,并在失败时自动切换到下一个`,
});
return (
<div
@@ -69,7 +78,7 @@ export function FailoverToggle({ className, activeApp }: FailoverToggleProps) {
<Switch
checked={isEnabled}
onCheckedChange={handleToggle}
disabled={setEnabled.isPending || isLoading}
disabled={setEnabled.isPending || isLoading || !takeoverEnabled}
/>
</div>
);
+1
View File
@@ -725,6 +725,7 @@ function ProviderQueueItem({
{/* 健康徽章 */}
<ProviderHealthBadge
consecutiveFailures={health?.consecutive_failures ?? 0}
isHealthy={health?.is_healthy}
/>
</div>
);
+33 -66
View File
@@ -35,6 +35,7 @@ export function ProxyTabContent({
const {
isRunning,
takeoverStatus,
startProxyServer,
stopWithRestore,
isPending: isProxyPending,
@@ -176,72 +177,38 @@ export function ProxyTabContent({
<TabsTrigger value="codex">Codex</TabsTrigger>
<TabsTrigger value="gemini">Gemini</TabsTrigger>
</TabsList>
<TabsContent value="claude" className="mt-4 space-y-6">
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
</div>
<FailoverQueueManager
appType="claude"
disabled={!isRunning}
/>
</div>
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel
appType="claude"
disabled={!isRunning}
/>
</div>
</TabsContent>
<TabsContent value="codex" className="mt-4 space-y-6">
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
</div>
<FailoverQueueManager
appType="codex"
disabled={!isRunning}
/>
</div>
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel
appType="codex"
disabled={!isRunning}
/>
</div>
</TabsContent>
<TabsContent value="gemini" className="mt-4 space-y-6">
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
</div>
<FailoverQueueManager
appType="gemini"
disabled={!isRunning}
/>
</div>
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel
appType="gemini"
disabled={!isRunning}
/>
</div>
</TabsContent>
{(["claude", "codex", "gemini"] as const).map((appType) => {
const failoverDisabled =
!isRunning || !(takeoverStatus?.[appType] ?? false);
return (
<TabsContent
key={appType}
value={appType}
className="mt-4 space-y-6"
>
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
</div>
<FailoverQueueManager
appType={appType}
disabled={failoverDisabled}
/>
</div>
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel
appType={appType}
disabled={failoverDisabled}
/>
</div>
</TabsContent>
);
})}
</Tabs>
</div>
</AccordionContent>