Skip to content

Commit 01230bf

Browse files
authored
Merge pull request #555 from realies/fix/ws-reconnect
fix(ws-error): add exponential reconnect mechanism
2 parents 0f6b3c2 + 6d9d712 commit 01230bf

File tree

1 file changed

+95
-20
lines changed

1 file changed

+95
-20
lines changed

ui/components/ChatWindow.tsx

Lines changed: 95 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import crypto from 'crypto';
99
import { toast } from 'sonner';
1010
import { useSearchParams } from 'next/navigation';
1111
import { getSuggestions } from '@/lib/actions';
12-
import Error from 'next/error';
12+
import NextError from 'next/error';
1313

1414
export type Message = {
1515
messageId: string;
@@ -32,11 +32,24 @@ const useSocket = (
3232
setIsWSReady: (ready: boolean) => void,
3333
setError: (error: boolean) => void,
3434
) => {
35-
const [ws, setWs] = useState<WebSocket | null>(null);
35+
const wsRef = useRef<WebSocket | null>(null);
36+
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
37+
const retryCountRef = useRef(0);
38+
const isCleaningUpRef = useRef(false);
39+
const MAX_RETRIES = 3;
40+
const INITIAL_BACKOFF = 1000; // 1 second
41+
42+
const getBackoffDelay = (retryCount: number) => {
43+
return Math.min(INITIAL_BACKOFF * Math.pow(2, retryCount), 10000); // Cap at 10 seconds
44+
};
3645

3746
useEffect(() => {
38-
if (!ws) {
39-
const connectWs = async () => {
47+
const connectWs = async () => {
48+
if (wsRef.current?.readyState === WebSocket.OPEN) {
49+
wsRef.current.close();
50+
}
51+
52+
try {
4053
let chatModel = localStorage.getItem('chatModel');
4154
let chatModelProvider = localStorage.getItem('chatModelProvider');
4255
let embeddingModel = localStorage.getItem('embeddingModel');
@@ -59,7 +72,13 @@ const useSocket = (
5972
'Content-Type': 'application/json',
6073
},
6174
},
62-
).then(async (res) => await res.json());
75+
).then(async (res) => {
76+
if (!res.ok)
77+
throw new Error(
78+
`Failed to fetch models: ${res.status} ${res.statusText}`,
79+
);
80+
return res.json();
81+
});
6382

6483
if (
6584
!chatModel ||
@@ -202,6 +221,7 @@ const useSocket = (
202221
wsURL.search = searchParams.toString();
203222

204223
const ws = new WebSocket(wsURL.toString());
224+
wsRef.current = ws;
205225

206226
const timeoutId = setTimeout(() => {
207227
if (ws.readyState !== 1) {
@@ -217,11 +237,16 @@ const useSocket = (
217237
const interval = setInterval(() => {
218238
if (ws.readyState === 1) {
219239
setIsWSReady(true);
240+
setError(false);
241+
if (retryCountRef.current > 0) {
242+
toast.success('Connection restored.');
243+
}
244+
retryCountRef.current = 0;
220245
clearInterval(interval);
221246
}
222247
}, 5);
223248
clearTimeout(timeoutId);
224-
console.log('[DEBUG] opened');
249+
console.debug(new Date(), 'ws:connected');
225250
}
226251
if (data.type === 'error') {
227252
toast.error(data.data);
@@ -230,24 +255,68 @@ const useSocket = (
230255

231256
ws.onerror = () => {
232257
clearTimeout(timeoutId);
233-
setError(true);
258+
setIsWSReady(false);
234259
toast.error('WebSocket connection error.');
235260
};
236261

237262
ws.onclose = () => {
238263
clearTimeout(timeoutId);
239-
setError(true);
240-
console.log('[DEBUG] closed');
264+
setIsWSReady(false);
265+
console.debug(new Date(), 'ws:disconnected');
266+
if (!isCleaningUpRef.current) {
267+
toast.error('Connection lost. Attempting to reconnect...');
268+
attemptReconnect();
269+
}
241270
};
271+
} catch (error) {
272+
console.debug(new Date(), 'ws:error', error);
273+
setIsWSReady(false);
274+
attemptReconnect();
275+
}
276+
};
242277

243-
setWs(ws);
244-
};
278+
const attemptReconnect = () => {
279+
retryCountRef.current += 1;
245280

246-
connectWs();
247-
}
248-
}, [ws, url, setIsWSReady, setError]);
281+
if (retryCountRef.current > MAX_RETRIES) {
282+
console.debug(new Date(), 'ws:max_retries');
283+
setError(true);
284+
toast.error(
285+
'Unable to connect to server after multiple attempts. Please refresh the page to try again.',
286+
);
287+
return;
288+
}
249289

250-
return ws;
290+
const backoffDelay = getBackoffDelay(retryCountRef.current);
291+
console.debug(
292+
new Date(),
293+
`ws:retry attempt=${retryCountRef.current}/${MAX_RETRIES} delay=${backoffDelay}ms`,
294+
);
295+
296+
if (reconnectTimeoutRef.current) {
297+
clearTimeout(reconnectTimeoutRef.current);
298+
}
299+
300+
reconnectTimeoutRef.current = setTimeout(() => {
301+
connectWs();
302+
}, backoffDelay);
303+
};
304+
305+
connectWs();
306+
307+
return () => {
308+
if (reconnectTimeoutRef.current) {
309+
clearTimeout(reconnectTimeoutRef.current);
310+
}
311+
if (wsRef.current?.readyState === WebSocket.OPEN) {
312+
wsRef.current.close();
313+
isCleaningUpRef.current = true;
314+
console.debug(new Date(), 'ws:cleanup');
315+
}
316+
};
317+
}, [url, setIsWSReady, setError]);
318+
319+
return wsRef.current;
251320
};
252321

253322
const loadMessages = async (
@@ -291,7 +360,7 @@ const loadMessages = async (
291360
return [msg.role, msg.content];
292361
}) as [string, string][];
293362

294-
console.log('[DEBUG] messages loaded');
363+
console.debug(new Date(), 'app:messages_loaded');
295364

296365
document.title = messages[0].content;
297366

@@ -373,7 +442,7 @@ const ChatWindow = ({ id }: { id?: string }) => {
373442
return () => {
374443
if (ws?.readyState === 1) {
375444
ws.close();
376-
console.log('[DEBUG] closed');
445+
console.debug(new Date(), 'ws:cleanup');
377446
}
378447
};
379448
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -388,12 +457,18 @@ const ChatWindow = ({ id }: { id?: string }) => {
388457
useEffect(() => {
389458
if (isMessagesLoaded && isWSReady) {
390459
setIsReady(true);
391-
console.log('[DEBUG] ready');
460+
console.debug(new Date(), 'app:ready');
461+
} else {
462+
setIsReady(false);
392463
}
393464
}, [isMessagesLoaded, isWSReady]);
394465

395466
const sendMessage = async (message: string, messageId?: string) => {
396467
if (loading) return;
468+
if (!ws || ws.readyState !== WebSocket.OPEN) {
469+
toast.error('Cannot send message while disconnected');
470+
return;
471+
}
397472

398473
setLoading(true);
399474
setMessageAppeared(false);
@@ -404,7 +479,7 @@ const ChatWindow = ({ id }: { id?: string }) => {
404479

405480
messageId = messageId ?? crypto.randomBytes(7).toString('hex');
406481

407-
ws?.send(
482+
ws.send(
408483
JSON.stringify({
409484
type: 'message',
410485
message: {
@@ -558,7 +633,7 @@ const ChatWindow = ({ id }: { id?: string }) => {
558633

559634
return isReady ? (
560635
notFound ? (
561-
<Error statusCode={404} />
636+
<NextError statusCode={404} />
562637
) : (
563638
<div>
564639
{messages.length > 0 ? (

0 commit comments

Comments
 (0)