Merge branch 'IDTEAM-1.26.0' into 'main'
[ProtonMail-WebClient.git] / packages / pass / lib / api / handlers.ts
blob36bf3fa4efe898de24146f20860ea32fba740608
1 import { type ApiAuth, type ApiCallFn, type ApiOptions, type ApiState, AuthMode, type Maybe } from '@proton/pass/types';
2 import type { ObjectHandler } from '@proton/pass/utils/object/handler';
3 import { getApiError } from '@proton/shared/lib/api/helpers/apiErrorHelper';
4 import { AppVersionBadError, InactiveSessionError } from '@proton/shared/lib/api/helpers/errors';
5 import { retryHandler } from '@proton/shared/lib/api/helpers/retryHandler';
6 import { OFFLINE_RETRY_ATTEMPTS_MAX, OFFLINE_RETRY_DELAY, RETRY_ATTEMPTS_MAX } from '@proton/shared/lib/constants';
7 import { API_CUSTOM_ERROR_CODES, HTTP_ERROR_CODES } from '@proton/shared/lib/errors';
8 import { withAuthHeaders, withUIDHeaders } from '@proton/shared/lib/fetch/headers';
9 import { wait } from '@proton/shared/lib/helpers/promise';
11 import { LockedSessionError, PassErrorCode } from './errors';
12 import type { RefreshHandler } from './refresh';
14 type ApiHandlersOptions = {
15     state: ObjectHandler<ApiState>;
16     call: ApiCallFn;
17     getAuth: () => Maybe<ApiAuth>;
18     refreshHandler: RefreshHandler;
21 /* Simplified version of withApiHandlers.js :
22  * - handles recursive session refresh
23  * - handles appBadVersion
24  * - handles basic offline error detection
25  * - FIXME: handle code 9001 errors with human verification  */
26 export const withApiHandlers = ({ state, call, getAuth, refreshHandler }: ApiHandlersOptions): ApiCallFn => {
27     return (options: ApiOptions) => {
28         const {
29             ignoreHandler = [],
30             retriesOnOffline = OFFLINE_RETRY_ATTEMPTS_MAX,
31             retriesOnTimeout = OFFLINE_RETRY_ATTEMPTS_MAX,
32         } = options ?? {};
34         const next = async (attempts: number, maxAttempts?: number): Promise<any> => {
35             if (state.get('appVersionBad')) throw AppVersionBadError();
37             /** Unauthenticated API calls should not be
38              * blocked by the current API error state. */
39             if (!options.unauthenticated) {
40                 if (state.get('sessionInactive')) throw InactiveSessionError();
41                 if (state.get('sessionLocked')) throw LockedSessionError();
42             }
44             try {
45                 /** Check if the request was queued and possibly aborted :
46                  * throw an error early to prevent triggering the API call */
47                 if (options.signal?.aborted) throw new Error('Aborted');
49                 const config = (() => {
50                     /** If the request was passed a custom UID - use it
51                      * instead of the standard authentication store value */
52                     const auth = options.auth ?? getAuth();
53                     if (!auth) return options;
55                     return auth.type === AuthMode.COOKIE
56                         ? withUIDHeaders(auth.UID, options)
57                         : withAuthHeaders(auth.UID, auth.AccessToken, options);
58                 })();
60                 return await call(config);
61             } catch (error: any) {
62                 const { status, name, response } = error;
63                 const { code } = getApiError(error);
65                 if (maxAttempts && attempts >= maxAttempts) throw error;
67                 /* Inactive extension session : only throw a `LockedSessionError`
68                  * when we have not reached the max amount of unlock tries. After
69                  * 3 unsuccessful attempts - we will get a 401 */
70                 if (code === PassErrorCode.SESSION_LOCKED && status === HTTP_ERROR_CODES.UNPROCESSABLE_ENTITY) {
71                     throw LockedSessionError();
72                 }
74                 if (code === API_CUSTOM_ERROR_CODES.APP_VERSION_BAD) throw AppVersionBadError();
76                 if (name === 'OfflineError') {
77                     if (attempts > retriesOnOffline) throw error;
78                     await wait(OFFLINE_RETRY_DELAY);
79                     return next(attempts + 1, retriesOnOffline);
80                 }
82                 if (name === 'TimeoutError') {
83                     if (attempts > retriesOnTimeout) throw error;
84                     return next(attempts + 1, retriesOnTimeout);
85                 }
87                 if (status === HTTP_ERROR_CODES.TOO_MANY_REQUESTS && !ignoreHandler.includes(status)) {
88                     await retryHandler(error);
89                     return next(attempts + 1, RETRY_ATTEMPTS_MAX);
90                 }
92                 if (status === HTTP_ERROR_CODES.UNPROCESSABLE_ENTITY) {
93                     /* Catch inactive session errors during cookie upgrade */
94                     if (code === PassErrorCode.INVALID_COOKIES_REFRESH) throw InactiveSessionError();
95                 }
97                 if (status === HTTP_ERROR_CODES.UNAUTHORIZED && !ignoreHandler.includes(status)) {
98                     if (code === PassErrorCode.SESSION_ERROR) throw InactiveSessionError();
100                     try {
101                         state.set('refreshing', true);
102                         await refreshHandler(response, options);
103                         return await next(attempts + 1, RETRY_ATTEMPTS_MAX);
104                     } catch (err: any) {
105                         if (err.status >= 400 && err.status <= 499) throw InactiveSessionError();
106                         throw err;
107                     } finally {
108                         state.set('refreshing', false);
109                     }
110                 }
112                 throw error;
113             }
114         };
116         return next(1);
117     };