From 61b9b1daa7b9227f2d268a305dba289905a6385a Mon Sep 17 00:00:00 2001 From: Helge Wiethoff Date: Wed, 29 Apr 2026 03:05:51 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=A9=B9=20fix(SSE):=20Treat=20`responseCod?= =?UTF-8?q?e=20=3D=3D=3D=200`=20as=20Transport=20Failure,=20Not=20Server?= =?UTF-8?q?=20Error=20(#12834)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sse): treat responseCode===0 as transport failure, not server error When a long-running model response (e.g. gpt-5.4 with web_search:true) takes longer than the browser's idle connection timeout, the SSE transport drops and sse.js fires an error event with responseCode=0 and e.data set to the raw response buffer (non-JSON SSE text). The previous guard `!responseCode` is truthy for both 0 (transport drop) and undefined (genuine server-sent error event), so the client incorrectly entered the server-error branch, tried to JSON.parse raw SSE text, logged "Failed to parse server error", and showed the user a red error banner -- even though the backend continued processing and delivered the final answer seconds later. Fix 1 (client): change guard from `!responseCode` to `responseCode == null` so that only undefined/null (no HTTP status at all) triggers the server-error parse path. responseCode===0 now correctly falls through to the reconnect path. Fix 2 (backend): after res.flushHeaders() the response is already committed as SSE. The fallback branch that wrote res.status(404).json() was an HTTP/SSE protocol violation. Replace with an SSE-conformant event:error frame + res.end(). * fix(sse): use onError helper on subscribe failure + add regression tests Replace silent res.end() with onError('Failed to subscribe to stream') so the client receives a parseable SSE error event instead of a stream that closes with no signal. The previous res.end() left the UI stuck in "submitting" state because no error/abort/final event ever fired. Also adds two missing test cases for the responseCode guard change: - responseCode === 0 with raw SSE buffer data must NOT call errorHandler (transport failure should reconnect, not display garbage) - responseCode == null with JSON error data MUST call errorHandler (server-sent error events should still surface to the user) --------- Co-authored-by: Danny Avila --- api/server/routes/agents/index.js | 3 +- .../SSE/__tests__/useResumableSSE.spec.ts | 47 +++++++++++++++++++ client/src/hooks/SSE/useResumableSSE.ts | 4 +- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js index eb42046bed..bbb39f5d2c 100644 --- a/api/server/routes/agents/index.js +++ b/api/server/routes/agents/index.js @@ -143,7 +143,8 @@ router.get('/chat/stream/:streamId', async (req, res) => { } if (!result) { - return res.status(404).json({ error: 'Failed to subscribe to stream' }); + onError('Failed to subscribe to stream'); + return; } req.on('close', () => { diff --git a/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts b/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts index 1717d27c22..269f38c865 100644 --- a/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts +++ b/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts @@ -281,4 +281,51 @@ describe('useResumableSSE - 404 error path', () => { unmount(); }, ); + + it('treats responseCode === 0 with raw SSE buffer data as transport failure (reconnect path)', async () => { + const submission = buildSubmission(); + const chatHelpers = buildChatHelpers(); + + const { unmount } = renderHook(() => useResumableSSE(submission, chatHelpers)); + + await act(async () => { + await Promise.resolve(); + }); + + const sse = getLastSSE(); + + await act(async () => { + sse._emit('error', { + responseCode: 0, + data: 'event: message\ndata: {"created":true,"message":{}}\n\n', + }); + }); + + expect(mockErrorHandler).not.toHaveBeenCalled(); + unmount(); + }); + + it('parses and surfaces server-sent error events (no responseCode, JSON data)', async () => { + const submission = buildSubmission(); + const chatHelpers = buildChatHelpers(); + + const { unmount } = renderHook(() => useResumableSSE(submission, chatHelpers)); + + await act(async () => { + await Promise.resolve(); + }); + + const sse = getLastSSE(); + + const errorPayload = JSON.stringify({ + error: JSON.stringify({ type: 'token_limit' }), + }); + + await act(async () => { + sse._emit('error', { data: errorPayload }); + }); + + expect(mockErrorHandler).toHaveBeenCalledTimes(1); + unmount(); + }); }); diff --git a/client/src/hooks/SSE/useResumableSSE.ts b/client/src/hooks/SSE/useResumableSSE.ts index 39dc610dae..f0ee12ecc1 100644 --- a/client/src/hooks/SSE/useResumableSSE.ts +++ b/client/src/hooks/SSE/useResumableSSE.ts @@ -391,8 +391,10 @@ export default function useResumableSSE( * Server-sent error event (event: error with data) - no responseCode. * These are known errors (ErrorTypes, ViolationTypes) that should be displayed to user. * Only check e.data if there's no HTTP responseCode, since HTTP errors may also have body data. + * Note: responseCode === 0 means transport failure (connection dropped) - treat as network error, + * not a server-sent error payload. Use `== null` to only match undefined/null (no HTTP status). */ - if (!responseCode && e.data) { + if (responseCode == null && e.data) { console.log('[ResumableSSE] Server-sent error event received:', e.data); sse.close(); removeActiveJob(currentStreamId);