diff --git a/src/app/core/factory/sdk/index.test.ts b/src/app/core/factory/sdk/index.test.ts index 8505d7e6d..38bd88c4f 100644 --- a/src/app/core/factory/sdk/index.test.ts +++ b/src/app/core/factory/sdk/index.test.ts @@ -8,6 +8,10 @@ import { Share, Users } from '@internxt/sdk/dist/drive'; import packageJson from '../../../../../package.json'; import { Auth } from '@internxt/sdk/dist/auth'; import { Location } from '@internxt/sdk'; +import { HttpClient } from '@internxt/sdk/dist/shared/http/client'; +import { USER_NOTIFICATION_MAX_RETRIES } from './retryStrategies'; +import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; +import dateService from 'services/date.service'; const MOCKED_NEW_API = 'https://api.internxt.com'; const MOCKED_PAYMENTS = 'https://payments.internxt.com'; @@ -20,6 +24,18 @@ vi.mock('@internxt/sdk/dist/drive', () => ({ Share: { client: vi.fn(), }, + Storage: { + client: vi.fn(), + }, +})); + +vi.mock('app/notifications/services/notifications.service', () => ({ + default: { + show: vi.fn(), + }, + ToastType: { + Warning: 'WARNING', + }, })); vi.mock('@internxt/sdk/dist/auth', () => ({ @@ -34,6 +50,22 @@ vi.mock('@internxt/sdk', () => ({ }, })); +vi.mock('@internxt/sdk/dist/shared/http/client', () => ({ + HttpClient: { + enableGlobalRetry: vi.fn(), + }, +})); + +vi.mock('i18next', () => ({ + t: vi.fn((key: string) => key), +})); + +vi.mock('services/date.service', () => ({ + default: { + hasElapsed: vi.fn().mockReturnValue(true), + }, +})); + vi.mock('services/env.service', () => ({ default: { getVariable: vi.fn((key: string) => { @@ -55,6 +87,12 @@ describe('SdkFactory', () => { let mockDispatch: any; let mockLocalStorage: LocalStorageService; + const getNotifyCallback = () => { + const callArgs = vi.mocked(HttpClient.enableGlobalRetry).mock.calls[0]; + const retryOptions = callArgs[0] as { onRetry: (attempt: number, delay: number) => void }; + return retryOptions.onRetry; + }; + beforeEach(() => { vi.clearAllMocks(); mockDispatch = vi.fn(); @@ -66,6 +104,31 @@ describe('SdkFactory', () => { SdkFactory.initialize(mockDispatch, mockLocalStorage); }); + describe('initialize', () => { + it('When initialized, then the global retry is enabled with user notification strategy', () => { + expect(HttpClient.enableGlobalRetry).toHaveBeenCalledTimes(1); + expect(HttpClient.enableGlobalRetry).toHaveBeenCalledWith( + expect.objectContaining({ maxRetries: USER_NOTIFICATION_MAX_RETRIES, onRetry: expect.any(Function) }), + ); + }); + + it('When SDK clients are created without calling initialize, then enableGlobalRetry is not called again', () => { + vi.mocked(HttpClient.enableGlobalRetry).mockClear(); + + vi.spyOn(mockLocalStorage, 'getWorkspace').mockReturnValue(Workspace.Individuals); + vi.spyOn(mockLocalStorage, 'get').mockImplementation((key: string) => { + if (key === 'xNewToken') return 'test-token'; + return null; + }); + + const instance = SdkFactory.getNewApiInstance(); + instance.createNewStorageClient(); + instance.createShareClient(); + + expect(HttpClient.enableGlobalRetry).not.toHaveBeenCalled(); + }); + }); + describe('getNewApiSecurity', () => { it('should return ApiSecurity with token and default unauthorized callback', () => { const mockToken = 'test-token'; @@ -365,5 +428,41 @@ describe('SdkFactory', () => { expect(Location.client).toHaveBeenCalledWith(MOCKED_LOCATION); }); }); + + describe('notifyUserWithCooldown', () => { + it('When notifyUserWithCooldown is called for the first time, then it shows a toast notification', () => { + const onRetry = getNotifyCallback(); + + onRetry(1, 1000); + + expect(notificationsService.show).toHaveBeenCalledWith({ + text: 'sdk.rateLimitToast', + type: ToastType.Warning, + duration: 60000, + }); + }); + + it('When notifyUserWithCooldown is called again within cooldown, then it does not show a second toast', () => { + const onRetry = getNotifyCallback(); + + onRetry(1, 1000); + expect(notificationsService.show).toHaveBeenCalledTimes(1); + + vi.mocked(dateService.hasElapsed).mockReturnValueOnce(false); + onRetry(2, 2000); + expect(notificationsService.show).toHaveBeenCalledTimes(1); + }); + + it('When notifyUserWithCooldown is called after cooldown has elapsed, then it shows the toast again', () => { + const onRetry = getNotifyCallback(); + + onRetry(1, 1000); + expect(notificationsService.show).toHaveBeenCalledTimes(1); + + vi.mocked(dateService.hasElapsed).mockReturnValueOnce(true); + onRetry(2, 2000); + expect(notificationsService.show).toHaveBeenCalledTimes(2); + }); + }); }); }); diff --git a/src/app/core/factory/sdk/index.ts b/src/app/core/factory/sdk/index.ts index 997ee8d52..21dd789cf 100644 --- a/src/app/core/factory/sdk/index.ts +++ b/src/app/core/factory/sdk/index.ts @@ -11,6 +11,34 @@ import { Checkout } from '@internxt/sdk/dist/payments'; import envService from 'services/env.service'; import { STORAGE_KEYS } from 'services/storage-keys'; import { Location } from '@internxt/sdk'; +import { HttpClient } from '@internxt/sdk/dist/shared/http/client'; +import dayjs, { Dayjs } from 'dayjs'; +import dateService from 'services/date.service'; +import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; +import { t } from 'i18next'; +import { retryStrategies, NotifyUserCallback } from './retryStrategies'; + +const RETRY_TOAST_DURATION_MS = 60000; +const RETRY_TOAST_COOLDOWN_MINUTES = 1; +let lastRetryToastShownAt: Dayjs | null = null; + +const notifyUserWithCooldown: NotifyUserCallback = () => { + const isToastOnCooldown = + lastRetryToastShownAt && !dateService.hasElapsed(lastRetryToastShownAt, RETRY_TOAST_COOLDOWN_MINUTES, 'minute'); + if (!isToastOnCooldown) { + lastRetryToastShownAt = dayjs(); + notificationsService.show({ + text: t('sdk.rateLimitToast'), + type: ToastType.Warning, + duration: RETRY_TOAST_DURATION_MS, + }); + } +}; + +const SdkClient = { + Storage: 'Storage', + Share: 'Share', +} as const; export class SdkFactory { private static sdk: { @@ -30,6 +58,8 @@ export class SdkFactory { localStorage, newApiInstance: new SdkFactory(envService.getVariable('newApi')), }; + + HttpClient.enableGlobalRetry(retryStrategies.withUserNotification(SdkClient.Storage, notifyUserWithCooldown)); } public static getNewApiInstance(): SdkFactory { diff --git a/src/app/core/factory/sdk/retryStrategies.test.ts b/src/app/core/factory/sdk/retryStrategies.test.ts new file mode 100644 index 000000000..e65069947 --- /dev/null +++ b/src/app/core/factory/sdk/retryStrategies.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { RetryOptions } from '@internxt/sdk/dist/shared'; +import { retryStrategies, SILENT_MAX_RETRIES, USER_NOTIFICATION_MAX_RETRIES } from './retryStrategies'; + +const getOnRetry = (options: RetryOptions): ((attempt: number, delay: number) => void) => { + if (!options.onRetry) throw new Error('onRetry callback was not provided'); + return options.onRetry; +}; + +describe('retryStrategies', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('silent', () => { + it('When a silent strategy is created, then it retries up to the configured max', () => { + const options = retryStrategies.silent('Test'); + + expect(options.maxRetries).toBe(SILENT_MAX_RETRIES); + }); + + it('When a request is retried, then it logs a warning without notifying the user', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const notifyUser = vi.fn(); + const options = retryStrategies.silent('Test'); + + getOnRetry(options)(1, 1000); + + expect(warnSpy).toHaveBeenCalledWith('[SDK] Test retry attempt 1, waiting 1000ms'); + expect(notifyUser).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + }); + + describe('withUserNotification', () => { + it('When a notification strategy is created, then it retries up to the configured max', () => { + const options = retryStrategies.withUserNotification('Test', vi.fn()); + + expect(options.maxRetries).toBe(USER_NOTIFICATION_MAX_RETRIES); + }); + + it('When a request is retried, then it calls the notifyUser callback', () => { + const notifyUser = vi.fn(); + const options = retryStrategies.withUserNotification('Test', notifyUser); + + getOnRetry(options)(1, 1000); + + expect(notifyUser).toHaveBeenCalledOnce(); + }); + + it('When a request is retried, then it logs a warning', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const options = retryStrategies.withUserNotification('Test', vi.fn()); + + getOnRetry(options)(1, 1000); + + expect(warnSpy).toHaveBeenCalledWith('[SDK] Test retry attempt 1, waiting 1000ms'); + warnSpy.mockRestore(); + }); + }); +}); diff --git a/src/app/core/factory/sdk/retryStrategies.ts b/src/app/core/factory/sdk/retryStrategies.ts new file mode 100644 index 000000000..4c7bdf148 --- /dev/null +++ b/src/app/core/factory/sdk/retryStrategies.ts @@ -0,0 +1,23 @@ +import { RetryOptions } from '@internxt/sdk/dist/shared'; + +export const SILENT_MAX_RETRIES = 2; +export const USER_NOTIFICATION_MAX_RETRIES = 5; + +export type NotifyUserCallback = () => void; + +export const retryStrategies = { + silent: (label = 'Global'): RetryOptions => ({ + maxRetries: SILENT_MAX_RETRIES, + onRetry(attempt, delay) { + console.warn(`[SDK] ${label} retry attempt ${attempt}, waiting ${delay}ms`); + }, + }), + + withUserNotification: (label: string, notifyUser: NotifyUserCallback): RetryOptions => ({ + maxRetries: USER_NOTIFICATION_MAX_RETRIES, + onRetry(attempt, delay) { + console.warn(`[SDK] ${label} retry attempt ${attempt}, waiting ${delay}ms`); + notifyUser(); + }, + }), +}; diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index ce0d787de..5d8202a6e 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -2115,5 +2115,8 @@ "todayAt": "heute um " }, "sharedWorkspace": "Gemeinsamer Arbeitsbereich" + }, + "sdk": { + "rateLimitToast": "Dies dauert länger als erwartet. Bitte warten..." } } diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index a64601317..88138249a 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -2199,5 +2199,8 @@ "todayAt": "today at " }, "sharedWorkspace": "Shared workspace" + }, + "sdk": { + "rateLimitToast": "This is taking longer than expected. Please wait..." } } diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json index 13eedbd62..fb61b59a7 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -2175,5 +2175,8 @@ "todayAt": "hoy a las " }, "sharedWorkspace": "Espacio de trabajo compartido" + }, + "sdk": { + "rateLimitToast": "Esto está tardando más de lo esperado. Por favor, espere..." } } diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json index a4a73f8d8..bca35a092 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -2121,5 +2121,8 @@ "todayAt": "aujourd'hui à " }, "sharedWorkspace": "Espace de travail partagé" + }, + "sdk": { + "rateLimitToast": "Cela prend plus de temps que prévu. Veuillez patienter..." } } diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json index 4d2923695..44595ea6b 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -2229,5 +2229,8 @@ "todayAt": "oggi alle " }, "sharedWorkspace": "Spazio di lavoro condiviso" + }, + "sdk": { + "rateLimitToast": "Ci sta volendo più del previsto. Attendere prego..." } } diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index 14d6dbce3..ed1c1f90e 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -2136,5 +2136,8 @@ "todayAt": "сегодня в " }, "sharedWorkspace": "Общее рабочее пространство" + }, + "sdk": { + "rateLimitToast": "Это занимает больше времени, чем ожидалось. Пожалуйста, подождите..." } } diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json index 222fc28a7..6b4eb7134 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -2126,5 +2126,8 @@ "todayAt": "今天在 " }, "sharedWorkspace": "共用工作區" + }, + "sdk": { + "rateLimitToast": "這花費的時間比預期的長,請稍候..." } } diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json index 67f1269f2..c6c1c69b0 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -2163,5 +2163,8 @@ "todayAt": "今天在 " }, "sharedWorkspace": "共享工作区" + }, + "sdk": { + "rateLimitToast": "这花费的时间比预期的长,请稍候..." } } diff --git a/src/services/date.service.ts b/src/services/date.service.ts index c7991de8c..0bf6beaf6 100644 --- a/src/services/date.service.ts +++ b/src/services/date.service.ts @@ -36,6 +36,9 @@ const getHoursUntilExpiration = (expiresAt: Date | string): number => { return Math.max(0, Math.ceil(dayjs(expiresAt).diff(dayjs(), 'hour', true))); }; +const hasElapsed = (since: Dayjs, amount: number, unit: dayjs.ManipulateType): boolean => + dayjs().diff(since, unit) >= amount; + const dateService = { format, isDateOneBefore, @@ -45,6 +48,7 @@ const dateService = { getDaysUntilExpiration, getDaysSince, getHoursUntilExpiration, + hasElapsed, }; export default dateService; diff --git a/src/upload.worker.ts b/src/upload.worker.ts index 413130b8a..c7da64d6d 100644 --- a/src/upload.worker.ts +++ b/src/upload.worker.ts @@ -1,4 +1,7 @@ import { uploadFile } from 'app/network/upload'; +import { HttpClient } from '@internxt/sdk/dist/shared'; + +HttpClient.enableGlobalRetry(); self.addEventListener('message', async (event) => { console.log('[WORKER]: Event received -->', event);