fix(web): SSE 连接添加指数退避重连策略
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

useAlertSSE hook 和 message store 的 connectSSE 均改为手动重连:
1s→2s→4s→8s→16s→30s(cap),最大重试 10 次,随机 jitter 0.5-1.0x。
替代浏览器原生 EventSource 固定 ~3s 重连,避免服务端压力。
This commit is contained in:
iven
2026-04-30 22:30:47 +08:00
parent 0dcaf7915f
commit 8f9895be98
2 changed files with 124 additions and 47 deletions

View File

@@ -24,49 +24,51 @@ export interface VitalUpdateSSEEvent {
occurred_at?: string; occurred_at?: string;
} }
type ConnectionState = 'connecting' | 'connected' | 'disconnected';
interface UseAlertSSEOptions { interface UseAlertSSEOptions {
/** 是否启用 SSE 连接(默认 true */
enabled?: boolean; enabled?: boolean;
/** 收到 alert 事件的回调 */
onAlert?: (data: AlertSSEEvent) => void; onAlert?: (data: AlertSSEEvent) => void;
/** 收到 vital_update 事件的回调 */
onVitalUpdate?: (data: VitalUpdateSSEEvent) => void; onVitalUpdate?: (data: VitalUpdateSSEEvent) => void;
} }
interface UseAlertSSEReturn { interface UseAlertSSEReturn {
/** 连接状态 */
connected: boolean; connected: boolean;
/** 最近收到的 alert 事件列表(最多保留 100 条) */ connectionState: ConnectionState;
recentAlerts: AlertSSEEvent[]; recentAlerts: AlertSSEEvent[];
/** 手动重连 */
reconnect: () => void; reconnect: () => void;
} }
const MAX_RECENT_ALERTS = 100; const MAX_RECENT_ALERTS = 100;
const INITIAL_DELAY_MS = 1000;
const MAX_DELAY_MS = 30_000;
const MAX_RETRIES = 10;
/**
* SSE 实时告警订阅 Hook。
*
* 封装 EventSource 连接管理,支持:
* - 自动重连EventSource 内置)
* - 事件分发alert / vital_update
* - 连接状态追踪
* - 最近告警缓存
*/
export function useAlertSSE(options: UseAlertSSEOptions = {}): UseAlertSSEReturn { export function useAlertSSE(options: UseAlertSSEOptions = {}): UseAlertSSEReturn {
const { enabled = true, onAlert, onVitalUpdate } = options; const { enabled = true, onAlert, onVitalUpdate } = options;
const eventSourceRef = useRef<EventSource | null>(null); const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const retryCountRef = useRef(0);
const reconnectKeyRef = useRef(0); const reconnectKeyRef = useRef(0);
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
const [recentAlerts, setRecentAlerts] = useState<AlertSSEEvent[]>([]); const [recentAlerts, setRecentAlerts] = useState<AlertSSEEvent[]>([]);
const clearReconnectTimer = useCallback(() => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
}, []);
const connect = useCallback(() => { const connect = useCallback(() => {
// 关闭旧连接
if (eventSourceRef.current) { if (eventSourceRef.current) {
eventSourceRef.current.close(); eventSourceRef.current.close();
eventSourceRef.current = null; eventSourceRef.current = null;
} }
clearReconnectTimer();
setConnected(false); setConnected(false);
setConnectionState('disconnected');
if (!enabled) return; if (!enabled) return;
@@ -75,11 +77,14 @@ export function useAlertSSE(options: UseAlertSSEOptions = {}): UseAlertSSEReturn
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api/v1'; const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api/v1';
const url = `${baseUrl}/messages/stream?token=${encodeURIComponent(token)}`; const url = `${baseUrl}/messages/stream?token=${encodeURIComponent(token)}`;
setConnectionState('connecting');
const es = new EventSource(url); const es = new EventSource(url);
eventSourceRef.current = es; eventSourceRef.current = es;
es.onopen = () => { es.onopen = () => {
retryCountRef.current = 0;
setConnected(true); setConnected(true);
setConnectionState('connected');
}; };
es.addEventListener('alert', (e: MessageEvent) => { es.addEventListener('alert', (e: MessageEvent) => {
@@ -90,25 +95,41 @@ export function useAlertSSE(options: UseAlertSSEOptions = {}): UseAlertSSEReturn
const next = [data, ...prev]; const next = [data, ...prev];
return next.length > MAX_RECENT_ALERTS ? next.slice(0, MAX_RECENT_ALERTS) : next; return next.length > MAX_RECENT_ALERTS ? next.slice(0, MAX_RECENT_ALERTS) : next;
}); });
} catch { } catch { /* ignore parse errors */ }
// 忽略解析失败的事件
}
}); });
es.addEventListener('vital_update', (e: MessageEvent) => { es.addEventListener('vital_update', (e: MessageEvent) => {
try { try {
const data = JSON.parse(e.data) as VitalUpdateSSEEvent; const data = JSON.parse(e.data) as VitalUpdateSSEEvent;
onVitalUpdate?.(data); onVitalUpdate?.(data);
} catch { } catch { /* ignore parse errors */ }
// 忽略解析失败的事件
}
}); });
es.onerror = () => { es.onerror = () => {
setConnected(false); setConnected(false);
// EventSource 会自动重连,无需手动处理 setConnectionState('disconnected');
es.close();
eventSourceRef.current = null;
retryCountRef.current += 1;
if (retryCountRef.current > MAX_RETRIES) {
return;
}
const delay = Math.min(
INITIAL_DELAY_MS * Math.pow(2, retryCountRef.current - 1),
MAX_DELAY_MS,
);
const jitter = delay * (0.5 + Math.random() * 0.5);
reconnectTimerRef.current = setTimeout(() => {
reconnectTimerRef.current = null;
const tokenNow = localStorage.getItem('access_token');
if (!tokenNow || !enabled) return;
connect();
}, jitter);
}; };
}, [enabled, onAlert, onVitalUpdate]); }, [enabled, onAlert, onVitalUpdate, clearReconnectTimer]);
useEffect(() => { useEffect(() => {
connect(); connect();
@@ -117,13 +138,16 @@ export function useAlertSSE(options: UseAlertSSEOptions = {}): UseAlertSSEReturn
eventSourceRef.current.close(); eventSourceRef.current.close();
eventSourceRef.current = null; eventSourceRef.current = null;
} }
clearReconnectTimer();
setConnected(false); setConnected(false);
setConnectionState('disconnected');
}; };
}, [connect, reconnectKeyRef.current]); }, [connect, reconnectKeyRef.current]);
const reconnect = useCallback(() => { const reconnect = useCallback(() => {
retryCountRef.current = 0;
reconnectKeyRef.current += 1; reconnectKeyRef.current += 1;
}, []); }, []);
return { connected, recentAlerts, reconnect }; return { connected, connectionState, recentAlerts, reconnect };
} }

View File

@@ -1,6 +1,10 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { getUnreadCount, listMessages, markRead, type MessageInfo } from '../api/messages'; import { getUnreadCount, listMessages, markRead, type MessageInfo } from '../api/messages';
const INITIAL_DELAY_MS = 1000;
const MAX_DELAY_MS = 30_000;
const MAX_RETRIES = 10;
interface MessageState { interface MessageState {
unreadCount: number; unreadCount: number;
recentMessages: MessageInfo[]; recentMessages: MessageInfo[];
@@ -71,32 +75,81 @@ export const useMessageStore = create<MessageState>((set, get) => ({
}, },
connectSSE: () => { connectSSE: () => {
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api/v1'; let es: EventSource | null = null;
const token = localStorage.getItem('access_token'); let retryCount = 0;
if (!token) return () => {}; let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let disposed = false;
const url = `${baseUrl}/messages/stream?token=${encodeURIComponent(token)}`; const clearReconnectTimer = () => {
const es = new EventSource(url); if (reconnectTimer) {
clearTimeout(reconnectTimer);
es.addEventListener('message', () => { reconnectTimer = null;
get().fetchUnreadCount(); }
get().fetchRecentMessages();
});
es.addEventListener('alert', () => {
get().fetchUnreadCount();
});
es.addEventListener('vital_update', () => {
// 体征数据更新事件 — 预留:未来可触发趋势图刷新
});
es.onerror = () => {
// SSE 连接断开时 EventSource 会自动重连
}; };
const connect = () => {
if (es) {
es.close();
es = null;
}
clearReconnectTimer();
const token = localStorage.getItem('access_token');
if (!token || disposed) return;
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api/v1';
const url = `${baseUrl}/messages/stream?token=${encodeURIComponent(token)}`;
es = new EventSource(url);
es.onopen = () => {
retryCount = 0;
};
es.addEventListener('message', () => {
get().fetchUnreadCount();
get().fetchRecentMessages();
});
es.addEventListener('alert', () => {
get().fetchUnreadCount();
});
es.addEventListener('vital_update', () => {
// 体征数据更新事件 — 预留:未来可触发趋势图刷新
});
es.onerror = () => {
es?.close();
es = null;
retryCount += 1;
if (retryCount > MAX_RETRIES || disposed) return;
const delay = Math.min(
INITIAL_DELAY_MS * Math.pow(2, retryCount - 1),
MAX_DELAY_MS,
);
const jitter = delay * (0.5 + Math.random() * 0.5);
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (disposed) return;
const tokenNow = localStorage.getItem('access_token');
if (!tokenNow) return;
connect();
}, jitter);
};
};
connect();
return () => { return () => {
es.close(); disposed = true;
if (es) {
es.close();
es = null;
}
clearReconnectTimer();
}; };
}, },
})); }));