Use same lock values as mobile clients
[ProtonMail-WebClient.git] / packages / shared / lib / api / helpers / refreshHandlers.ts
blob4b313517e3269cf66dfb65a931a6e338cd377905
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) => {
22                 try {
23                     await mutex.lock(UID);
24                     return () => {
25                         return mutex.unlock(UID).catch(noop);
26                     };
27                 } catch (e) {
28                     // If getting the mutex fails, fall back to a random wait
29                     await wait(randomIntFromInterval(100, 2000));
30                     return () => {
31                         return Promise.resolve();
32                     };
33                 }
34             };
36             /**
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.
40              */
41             refreshHandlers[UID] = createOnceHandler(async (responseDate: Date = new Date()) => {
42                 const unlockMutex = await getMutexLock(UID);
43                 try {
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
49                         await wait(50);
50                     }
51                 } finally {
52                     await unlockMutex();
53                 }
54             });
55         }
57         return refreshHandlers[UID](responseDate);
58     };
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;
68 /**
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.
72  */
73 export const refresh = (call: () => Promise<Response>, attempts: number, maxAttempts: number): Promise<Response> => {
74     return call().catch((e) => {
75         if (attempts >= maxAttempts) {
76             throw e;
77         }
79         const { status, name } = e;
81         if (name === 'OfflineError') {
82             if (attempts > OFFLINE_RETRY_ATTEMPTS_MAX) {
83                 throw e;
84             }
85             return wait(OFFLINE_RETRY_DELAY).then(() => refresh(call, attempts + 1, OFFLINE_RETRY_ATTEMPTS_MAX));
86         }
88         if (name === 'TimeoutError') {
89             if (attempts > OFFLINE_RETRY_ATTEMPTS_MAX) {
90                 throw e;
91             }
92             return refresh(call, attempts + 1, OFFLINE_RETRY_ATTEMPTS_MAX);
93         }
95         if (status === HTTP_ERROR_CODES.TOO_MANY_REQUESTS) {
96             return retryHandler(e).then(() => refresh(call, attempts + 1, RETRY_ATTEMPTS_MAX));
97         }
99         throw e;
100     });