🩹 fix(SSE): Treat responseCode === 0 as Transport Failure, Not Server Error (#12834)

* 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 <danny@librechat.ai>
This commit is contained in:
Helge Wiethoff 2026-04-29 03:05:51 +02:00 committed by GitHub
parent 85894c11c7
commit 61b9b1daa7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 52 additions and 2 deletions

View file

@ -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', () => {

View file

@ -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();
});
});

View file

@ -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);