Compare commits

...
Author SHA1 Message Date
Félix Malfait af075d4f83 fix(auth): extract retryWithBackoff utility and remove stale comments
Made-with: Cursor
2026-03-18 14:47:50 +01:00
Félix Malfait ac7f150583 fix(auth): use dynamic SSE headers and add token renewal retry logic
- Make graphql-sse headers a function so the latest token is used on
  each reconnection attempt instead of the stale initial token
- Remove token-mismatch SSE client reset since dynamic headers make it
  unnecessary
- Let AuthService errors propagate with original type so callers can
  distinguish permanent vs transient failures
- Add retry loop with linear backoff (up to 3 retries) for transient
  token renewal failures, while immediately failing on server rejections

Made-with: Cursor
2026-03-17 18:02:10 +01:00
5 changed files with 82 additions and 43 deletions
@@ -16,6 +16,7 @@ import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMembe
import { type CurrentWorkspace } from '@/auth/states/currentWorkspaceState';
import { type AuthTokenPair } from '~/generated-metadata/graphql';
import { logDebug } from '~/utils/logDebug';
import { retryWithBackoff } from '~/utils/retryWithBackoff';
import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url';
import { type ApolloManager } from '@/apollo/types/apolloManager.interface';
@@ -43,6 +44,9 @@ const logger = loggerLink(() => 'Twenty');
// deduplicate into a single renewal request.
let renewalPromise: Promise<void> | null = null;
const TOKEN_RENEWAL_MAX_RETRIES = 3;
const TOKEN_RENEWAL_RETRY_DELAY_MS = 1000;
export interface Options {
uri: string;
cache: ApolloClient.Options['cache'];
@@ -156,27 +160,35 @@ export class ApolloFactory implements ApolloManager {
},
});
const attemptTokenRenewal = async (): Promise<void> => {
const graphqlUri = `${REACT_APP_SERVER_BASE_URL}/metadata`;
const tokens = await retryWithBackoff(
() => renewToken(graphqlUri, getTokenPair()),
{
maxRetries: TOKEN_RENEWAL_MAX_RETRIES,
baseDelayMs: TOKEN_RENEWAL_RETRY_DELAY_MS,
shouldRetry: (error) =>
!CombinedGraphQLErrors.is(error) && isDefined(getTokenPair()),
},
);
if (isDefined(tokens)) {
onTokenPairChange?.(tokens);
cookieStorage.setItem('tokenPair', JSON.stringify(tokens));
}
};
const handleTokenRenewal = (
operation: ApolloLink.Operation,
forward: ApolloLink.ForwardFunction,
) => {
if (!renewalPromise) {
// Always renew through /metadata since the RenewToken is only exposed there
const graphqlUri = `${REACT_APP_SERVER_BASE_URL}/metadata`;
renewalPromise = renewToken(graphqlUri, getTokenPair())
.then((tokens) => {
if (isDefined(tokens)) {
// oxlint-disable-next-line no-console
console.log('setTokenPair from handleTokenRenewal');
onTokenPairChange?.(tokens);
cookieStorage.setItem('tokenPair', JSON.stringify(tokens));
}
})
renewalPromise = attemptTokenRenewal()
.catch(() => {
// oxlint-disable-next-line no-console
console.log(
'Failed to renew token, triggering unauthenticated error from handleTokenRenewal',
'Failed to renew token after retries, triggering unauthenticated error',
);
onUnauthenticatedError?.();
})
@@ -22,34 +22,27 @@ const renewTokenMutation = async (
) => {
const httpLink = new HttpLink({ uri });
// Create new client to call refresh token graphql mutation
const client = new ApolloClient({
link: ApolloLink.from([logger, httpLink]),
cache: new InMemoryCache({}),
});
let data: RenewTokenMutation | null | undefined;
try {
const result = await client.mutate<
RenewTokenMutation,
RenewTokenMutationVariables
>({
mutation: RenewTokenDocument,
variables: {
appToken: refreshToken,
},
fetchPolicy: 'network-only',
});
data = result.data;
} catch {
throw new Error('Something went wrong during token renewal');
const result = await client.mutate<
RenewTokenMutation,
RenewTokenMutationVariables
>({
mutation: RenewTokenDocument,
variables: {
appToken: refreshToken,
},
fetchPolicy: 'network-only',
});
if (isUndefinedOrNull(result.data)) {
throw new Error('Token renewal returned empty data');
}
if (isUndefinedOrNull(data)) {
throw new Error('Something went wrong during token renewal');
}
return data;
return result.data;
};
export const renewToken = async (
@@ -33,19 +33,22 @@ export const SSEClientEffect = () => {
useEffect(() => {
if (hasAccessTokenPair && !isDefined(sseClient) && isDefined(tokenPair)) {
const token = tokenPair?.accessOrWorkspaceAgnosticToken?.token;
const newSseClient = createClient({
url: `${REACT_APP_SERVER_BASE_URL}/metadata`,
headers: {
Authorization: token ? `Bearer ${token}` : '',
headers: () => {
const currentTokenPair = store.get(tokenPairState.atom);
const token = currentTokenPair?.accessOrWorkspaceAgnosticToken?.token;
return {
Authorization: token ? `Bearer ${token}` : '',
};
},
on: {
connected: handleSSEClientConnected,
},
retryAttempts: Infinity,
retry: (retryCount: number) =>
handleSseClientConnectionRetry(retryCount, token),
handleSseClientConnectionRetry(retryCount),
});
setSseClient(newSseClient);
@@ -55,6 +58,7 @@ export const SSEClientEffect = () => {
hasAccessTokenPair,
setSseClient,
sseClient,
store,
tokenPair,
handleSseClientConnectionRetry,
]);
@@ -13,7 +13,7 @@ import { useStore } from 'jotai';
export const useHandleSseClientConnectionRetry = () => {
const store = useStore();
const handleSseClientConnectionRetry = useCallback(
async (retryCount: number, initialTokenForSseClient: string) => {
async (retryCount: number) => {
const sseClient = store.get(sseClientState.atom);
if (!isDefined(sseClient)) {
@@ -28,9 +28,7 @@ export const useHandleSseClientConnectionRetry = () => {
const currentAppToken = tokenPair?.accessOrWorkspaceAgnosticToken?.token;
const shouldResetSseClient =
!isDefined(currentAppToken) ||
currentAppToken !== initialTokenForSseClient ||
retryCount > 10;
!isDefined(currentAppToken) || retryCount > 10;
if (shouldResetSseClient) {
await sleep(
@@ -0,0 +1,32 @@
import { sleep } from '~/utils/sleep';
type ShouldRetryFn = (error: unknown) => boolean;
export const retryWithBackoff = async <T>(
fn: () => Promise<T>,
{
maxRetries = 3,
baseDelayMs = 1000,
shouldRetry,
}: {
maxRetries?: number;
baseDelayMs?: number;
shouldRetry: ShouldRetryFn;
},
): Promise<T> => {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
const isLastAttempt = attempt === maxRetries;
if (!shouldRetry(error) || isLastAttempt) {
throw error;
}
await sleep(baseDelayMs * (attempt + 1));
}
}
throw new Error('Unreachable');
};