🏁 fix: Ignore Finalized SSE Transport Errors (#14114)

* fix: Ignore Finalized SSE Transport Errors

* test: Rename Resumable SSE Suite
This commit is contained in:
Danny Avila 2026-07-05 11:33:35 -04:00 • committed by GitHub
parent e8d76542b6
commit f11b7379a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 71 additions and 3 deletions

View file

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

View file

@ -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<Parameters<typeof stepHandler>[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) {