Fixed SSE connection retry infinite loop (#17723)

This bug was cause by a combination of an expired token stored inside
SSE client on the front end, and a server restart or a cache flush, we
ended up with an SSE client that tries to reconnect infinitely with an
expired token.

The fix is to improve the connection retry handler in the frontend to
detect an expired token and recreate an SSE client.
This commit is contained in:
Lucas Bordeau
2026-02-05 13:19:43 +01:00
committed by GitHub
parent 309785f7ff
commit 66b87c641c
5 changed files with 87 additions and 31 deletions
@@ -1,7 +1,6 @@
import { getTokenPair } from '@/apollo/utils/getTokenPair';
import { useIsLogged } from '@/auth/hooks/useIsLogged';
import { SSE_CONNECTION_RETRY_MAX_WAIT_TIME_IN_MS } from '@/sse-db-event/constants/SseConnectionRetryMaxWaitTimeInMs';
import { SSE_CONNECTION_RETRY_WAIT_TIME_IN_MS_FOR_DEV_MODE } from '@/sse-db-event/constants/SseConnectionRetryWaitTimeInMsForDevMode';
import { tokenPairState } from '@/auth/states/tokenPairState';
import { useHandleSseClientConnectionRetry } from '@/sse-db-event/hooks/useHandleSseClientConnectionRetry';
import { activeQueryListenersState } from '@/sse-db-event/states/activeQueryListenersState';
import { sseClientState } from '@/sse-db-event/states/sseClientState';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
@@ -11,13 +10,11 @@ import { useEffect } from 'react';
import { useRecoilCallback, useRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { getIsDevelopmentEnvironment } from '~/utils/getIsDevelopmentEnvironment';
import { sleep } from '~/utils/sleep';
export const SSEClientEffect = () => {
const isLoggedIn = useIsLogged();
const [sseClient, setSseClient] = useRecoilState(sseClientState);
const [tokenPair] = useRecoilState(tokenPairState);
const handleSSEClientConnected = useRecoilCallback(
({ snapshot, set }) =>
@@ -34,9 +31,11 @@ export const SSEClientEffect = () => {
[],
);
const { handleSseClientConnectionRetry } =
useHandleSseClientConnectionRetry();
useEffect(() => {
if (isLoggedIn && !isDefined(sseClient)) {
const tokenPair = getTokenPair();
if (isLoggedIn && !isDefined(sseClient) && isDefined(tokenPair)) {
const token = tokenPair?.accessOrWorkspaceAgnosticToken?.token;
const newSseClient = createClient({
@@ -48,24 +47,20 @@ export const SSEClientEffect = () => {
connected: handleSSEClientConnected,
},
retryAttempts: Infinity,
retry: async () => {
const randomWaitTimeInMsToSpaceAllClientsReconnection = Math.round(
Math.random() * SSE_CONNECTION_RETRY_MAX_WAIT_TIME_IN_MS,
);
const isDevelopmentEnvironment = getIsDevelopmentEnvironment();
const waitTimeInMs = isDevelopmentEnvironment
? SSE_CONNECTION_RETRY_WAIT_TIME_IN_MS_FOR_DEV_MODE
: randomWaitTimeInMsToSpaceAllClientsReconnection;
await sleep(waitTimeInMs);
},
retry: (retryCount: number) =>
handleSseClientConnectionRetry(retryCount, token),
});
setSseClient(newSseClient);
}
}, [handleSSEClientConnected, isLoggedIn, setSseClient, sseClient]);
}, [
handleSSEClientConnected,
isLoggedIn,
setSseClient,
sseClient,
tokenPair,
handleSseClientConnectionRetry,
]);
return null;
};
@@ -6,7 +6,6 @@ import { useTriggerEventStreamDestroy } from '@/sse-db-event/hooks/useTriggerEve
import { isCreatingSseEventStreamState } from '@/sse-db-event/states/isCreatingSseEventStreamState';
import { isDestroyingEventStreamState } from '@/sse-db-event/states/isDestroyingEventStreamState';
import { shouldDestroyEventStreamState } from '@/sse-db-event/states/shouldDestroyEventStreamState';
import { sseClientState } from '@/sse-db-event/states/sseClientState';
import { sseEventStreamIdState } from '@/sse-db-event/states/sseEventStreamIdState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isNonEmptyArray } from '@apollo/client/utilities';
@@ -17,7 +16,6 @@ import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey, OnboardingStatus } from '~/generated/graphql';
export const SSEEventStreamEffect = () => {
const sseClient = useRecoilValue(sseClientState);
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const sseEventStreamId = useRecoilValue(sseEventStreamIdState);
@@ -40,9 +38,7 @@ export const SSEEventStreamEffect = () => {
useEffect(() => {
const isSseClientAvailabble =
isDefined(sseClient) &&
!isCreatingSseEventStream &&
!isDestroyingEventStream;
!isCreatingSseEventStream && !isDestroyingEventStream;
const willCreateEventStream =
isSseClientAvailabble &&
@@ -62,7 +58,7 @@ export const SSEEventStreamEffect = () => {
if (willDestroyEventStream) {
triggerEventStreamDestroy();
} else if (willCreateEventStream) {
triggerEventStreamCreation(sseClient);
triggerEventStreamCreation();
}
}, [
isCreatingSseEventStream,
@@ -72,7 +68,6 @@ export const SSEEventStreamEffect = () => {
isSseDbEventsEnabled,
isDestroyingEventStream,
triggerEventStreamDestroy,
sseClient,
shouldDestroyEventStream,
sseEventStreamId,
objectMetadataItems,
@@ -0,0 +1 @@
export const SSE_CONNECTION_RETRY_WAIT_TIME_IN_MS_TO_AVOID_RACE_CONDITIONS = 1_000;
@@ -0,0 +1,63 @@
import { tokenPairState } from '@/auth/states/tokenPairState';
import { SSE_CONNECTION_RETRY_MAX_WAIT_TIME_IN_MS } from '@/sse-db-event/constants/SseConnectionRetryMaxWaitTimeInMs';
import { SSE_CONNECTION_RETRY_WAIT_TIME_IN_MS_FOR_DEV_MODE } from '@/sse-db-event/constants/SseConnectionRetryWaitTimeInMsForDevMode';
import { SSE_CONNECTION_RETRY_WAIT_TIME_IN_MS_TO_AVOID_RACE_CONDITIONS } from '@/sse-db-event/constants/SseConnectionRetryWaitTimeInMsToAvoidRaceConditions';
import { shouldDestroyEventStreamState } from '@/sse-db-event/states/shouldDestroyEventStreamState';
import { sseClientState } from '@/sse-db-event/states/sseClientState';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { getIsDevelopmentEnvironment } from '~/utils/getIsDevelopmentEnvironment';
import { sleep } from '~/utils/sleep';
export const useHandleSseClientConnectionRetry = () => {
const handleSseClientConnectionRetry = useRecoilCallback(
({ snapshot, set }) =>
async (retryCount: number, initialTokenForSseClient: string) => {
const sseClient = getSnapshotValue(snapshot, sseClientState);
if (!isDefined(sseClient)) {
await sleep(
SSE_CONNECTION_RETRY_WAIT_TIME_IN_MS_TO_AVOID_RACE_CONDITIONS,
);
return;
}
const tokenPair = getSnapshotValue(snapshot, tokenPairState);
const currentAppToken =
tokenPair?.accessOrWorkspaceAgnosticToken?.token;
const shouldResetSseClient =
!isDefined(currentAppToken) ||
currentAppToken !== initialTokenForSseClient ||
retryCount > 10;
if (shouldResetSseClient) {
await sleep(
SSE_CONNECTION_RETRY_WAIT_TIME_IN_MS_TO_AVOID_RACE_CONDITIONS,
);
sseClient.dispose();
set(shouldDestroyEventStreamState, true);
set(sseClientState, null);
return;
}
const randomWaitTimeInMsToSpaceAllClientsReconnection = Math.round(
Math.random() * SSE_CONNECTION_RETRY_MAX_WAIT_TIME_IN_MS,
);
const isDevelopmentEnvironment = getIsDevelopmentEnvironment();
const waitTimeInMs = isDevelopmentEnvironment
? SSE_CONNECTION_RETRY_WAIT_TIME_IN_MS_FOR_DEV_MODE
: randomWaitTimeInMsToSpaceAllClientsReconnection;
await sleep(waitTimeInMs);
},
[],
);
return { handleSseClientConnectionRetry };
};
@@ -5,12 +5,12 @@ import { disposeFunctionForEventStreamState } from '@/sse-db-event/states/dispos
import { isCreatingSseEventStreamState } from '@/sse-db-event/states/isCreatingSseEventStreamState';
import { isDestroyingEventStreamState } from '@/sse-db-event/states/isDestroyingEventStreamState';
import { shouldDestroyEventStreamState } from '@/sse-db-event/states/shouldDestroyEventStreamState';
import { sseClientState } from '@/sse-db-event/states/sseClientState';
import { sseEventStreamIdState } from '@/sse-db-event/states/sseEventStreamIdState';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { captureException } from '@sentry/react';
import { isNonEmptyString } from '@sniptt/guards';
import { print, type ExecutionResult } from 'graphql';
import { type Client } from 'graphql-sse';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
@@ -30,7 +30,9 @@ export const useTriggerEventStreamCreation = () => {
const triggerEventStreamCreation = useRecoilCallback(
({ snapshot, set }) =>
(sseClient: Client) => {
() => {
const sseClient = snapshot.getLoadable(sseClientState).getValue();
const isCreatingSseEventStream = snapshot
.getLoadable(isCreatingSseEventStreamState)
.getValue();