1 import { create as createMutex } from '@protontech/mutex-browser';
3 import { getLastRefreshDate, setLastRefreshDate } from '@proton/shared/lib/api/helpers/refreshStorage';
4 import { retryHandler } from '@proton/shared/lib/api/helpers/retryHandler';
5 import { createOnceHandler } from '@proton/shared/lib/apiHandlers';
6 import { OFFLINE_RETRY_ATTEMPTS_MAX, OFFLINE_RETRY_DELAY, RETRY_ATTEMPTS_MAX } from '@proton/shared/lib/constants';
7 import { HTTP_ERROR_CODES } from '@proton/shared/lib/errors';
8 import type { ApiError } from '@proton/shared/lib/fetch/ApiError';
9 import { getDateHeader } from '@proton/shared/lib/fetch/helpers';
10 import { wait } from '@proton/shared/lib/helpers/promise';
11 import noop from '@proton/utils/noop';
12 import randomIntFromInterval from '@proton/utils/randomIntFromInterval';
14 export const createRefreshHandlers = (refresh: (UID: string) => Promise<Response>) => {
15 const refreshHandlers: { [key: string]: (date: Date | undefined) => Promise<void> } = {};
17 const refreshHandler = (UID: string, responseDate: Date | undefined) => {
18 if (!refreshHandlers[UID]) {
19 const mutex = createMutex({ expiry: 15000 });
21 const getMutexLock = async (UID: string) => {
23 await mutex.lock(UID);
25 return mutex.unlock(UID).catch(noop);
28 // If getting the mutex fails, fall back to a random wait
29 await wait(randomIntFromInterval(100, 2000));
31 return Promise.resolve();
37 * Refreshing the session needs to handle multiple race conditions.
38 * 1) Race conditions within the context (tab). Solved by the once handler.
39 * 2) Race conditions within multiple contexts (tabs). Solved by the shared mutex.
41 refreshHandlers[UID] = createOnceHandler(async (responseDate: Date = new Date()) => {
42 const unlockMutex = await getMutexLock(UID);
44 const lastRefreshDate = getLastRefreshDate(UID);
45 if (lastRefreshDate === undefined || responseDate > lastRefreshDate) {
46 const result = await refresh(UID);
47 setLastRefreshDate(UID, getDateHeader(result.headers) || new Date());
48 // Add an artificial delay to ensure cookies are properly updated to avoid race conditions
57 return refreshHandlers[UID](responseDate);
60 return refreshHandler;
63 export const getIsRefreshFailure = (error: ApiError) => {
64 // Any 4xx from the refresh call and the session is no longer valid, 429 is already handled in the refreshHandler
65 return error.status >= 400 && error.status <= 499;
69 * Handle refresh token. Happens when the access token has expired.
70 * Multiple calls can fail, so this ensures the refresh route is called once.
71 * Needs to re-handle errors here for that reason.
73 export const refresh = (call: () => Promise<Response>, attempts: number, maxAttempts: number): Promise<Response> => {
74 return call().catch((e) => {
75 if (attempts >= maxAttempts) {
79 const { status, name } = e;
81 if (name === 'OfflineError') {
82 if (attempts > OFFLINE_RETRY_ATTEMPTS_MAX) {
85 return wait(OFFLINE_RETRY_DELAY).then(() => refresh(call, attempts + 1, OFFLINE_RETRY_ATTEMPTS_MAX));
88 if (name === 'TimeoutError') {
89 if (attempts > OFFLINE_RETRY_ATTEMPTS_MAX) {
92 return refresh(call, attempts + 1, OFFLINE_RETRY_ATTEMPTS_MAX);
95 if (status === HTTP_ERROR_CODES.TOO_MANY_REQUESTS) {
96 return retryHandler(e).then(() => refresh(call, attempts + 1, RETRY_ATTEMPTS_MAX));