From f11b7379a12e3d76671a802a305787618fda4af4 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 5 Jul 2026 11:33:35 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=81=20fix:=20Ignore=20Finalized=20SSE?= =?UTF-8?q?=20Transport=20Errors=20(#14114)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Ignore Finalized SSE Transport Errors * test: Rename Resumable SSE Suite --- .../SSE/__tests__/useResumableSSE.spec.ts | 55 ++++++++++++++++++- client/src/hooks/SSE/useResumableSSE.ts | 19 ++++++- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts b/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts index 50e13b3078..3f0a37a803 100644 --- a/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts +++ b/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts @@ -236,7 +236,7 @@ const advanceRetryTimer = async (ms: number) => { await flushMicrotasks(); }; -describe('useResumableSSE - 404 error path', () => { +describe('useResumableSSE', () => { beforeEach(() => { mockSSEInstances.length = 0; localStorage.clear(); @@ -1612,6 +1612,59 @@ describe('useResumableSSE - 404 error path', () => { unmount(); }); + it('ignores responseCode === 0 after FINAL instead of reconnecting a completed stream', async () => { + jest.useFakeTimers(); + const submission = buildSubmission(); + const chatHelpers = buildChatHelpers(); + + const { unmount } = renderHook(() => useResumableSSE(submission, chatHelpers)); + + await act(async () => { + await Promise.resolve(); + }); + + const sse = getLastSSE(); + const sseCount = mockSSEInstances.length; + const finalPayload = { + final: true, + conversation: { conversationId: CONV_ID }, + requestMessage: { + messageId: 'msg-1', + conversationId: CONV_ID, + text: 'Hello', + isCreatedByUser: true, + }, + responseMessage: { + messageId: 'resp-1', + parentMessageId: 'msg-1', + conversationId: CONV_ID, + text: 'Done', + isCreatedByUser: false, + }, + }; + + await act(async () => { + sse._emit('message', { + data: JSON.stringify(finalPayload), + }); + }); + + expect(sse.close).toHaveBeenCalled(); + mockSetIsSubmitting.mockClear(); + mockSetShowStopButton.mockClear(); + + await act(async () => { + sse._emit('error', { responseCode: 0 }); + }); + await advanceRetryTimer(1000); + + expect(mockSSEInstances).toHaveLength(sseCount); + expect(mockSetIsSubmitting).not.toHaveBeenCalledWith(true); + expect(mockSetShowStopButton).not.toHaveBeenCalledWith(true); + expect(mockErrorHandler).not.toHaveBeenCalled(); + unmount(); + }); + it('parses and surfaces server-sent error events (no responseCode, JSON data)', async () => { const submission = buildSubmission(); const chatHelpers = buildChatHelpers(); diff --git a/client/src/hooks/SSE/useResumableSSE.ts b/client/src/hooks/SSE/useResumableSSE.ts index 4bfcdfa22a..811907c77c 100644 --- a/client/src/hooks/SSE/useResumableSSE.ts +++ b/client/src/hooks/SSE/useResumableSSE.ts @@ -573,6 +573,7 @@ export default function useResumableSSE( (currentStreamId: string, currentSubmission: TSubmission, isResume = false) => { let { userMessage } = currentSubmission; let textIndex: number | null = null; + let finalReceived = false; const preCreatedStepEvents: Array[0]> = []; const replayPreCreatedStepEvents = () => { if (preCreatedStepEvents.length === 0) { @@ -663,6 +664,12 @@ export default function useResumableSSE( const data = JSON.parse(e.data); if (data.final != null) { + finalReceived = true; + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + reconnectAttemptRef.current = 0; logger.log('ResumableSSE', 'Received FINAL event', { aborted: data.aborted, conversationId: data.conversation?.conversationId, @@ -996,11 +1003,19 @@ export default function useResumableSSE( * Order matters: check responseCode first since HTTP errors may also include data */ sse.addEventListener('error', async (e: MessageEvent) => { - (startupConfig?.balance?.enabled ?? false) && balanceQuery.refetch(); - /* @ts-ignore - sse.js types don't expose responseCode */ const responseCode = e.responseCode; + if (finalReceived) { + logger.log('ResumableSSE', 'Ignoring error after FINAL event', { + responseCode, + hasData: !!e.data, + }); + return; + } + + (startupConfig?.balance?.enabled ?? false) && balanceQuery.refetch(); + // 404 → job completed & was cleaned up; messages are persisted in DB. // Invalidate cache once so react-query refetches instead of showing an error. if (responseCode === 404) {