From 330514d5e05c4c2dac7dca3cc3309c0817a2ddfb Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 24 Feb 2026 12:52:49 -0400 Subject: [PATCH 1/9] feat: add global retry with rate-limit notifications for SDK clients Upgrade @internxt/sdk to 1.15.0 and introduce retry strategies for HTTP requests. Silent retries (2 attempts) are enabled globally, while Storage and Share clients use a user-facing notification strategy (5 attempts) with a cooldown-based warning toast. Adds hasElapsed utility to date.service and i18n translations for the rate-limit toast in all supported languages --- src/app/core/factory/sdk/index.test.ts | 19 ++++ src/app/core/factory/sdk/index.ts | 19 +++- .../core/factory/sdk/retryStrategies.test.ts | 99 +++++++++++++++++++ src/app/core/factory/sdk/retryStrategies.ts | 39 ++++++++ src/app/i18n/locales/de.json | 3 + src/app/i18n/locales/en.json | 3 + src/app/i18n/locales/es.json | 3 + src/app/i18n/locales/fr.json | 3 + src/app/i18n/locales/it.json | 3 + src/app/i18n/locales/ru.json | 3 + src/app/i18n/locales/tw.json | 3 + src/app/i18n/locales/zh.json | 3 + src/services/date.service.ts | 4 + 13 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 src/app/core/factory/sdk/retryStrategies.test.ts create mode 100644 src/app/core/factory/sdk/retryStrategies.ts diff --git a/src/app/core/factory/sdk/index.test.ts b/src/app/core/factory/sdk/index.test.ts index 8505d7e6d..eda3f0c85 100644 --- a/src/app/core/factory/sdk/index.test.ts +++ b/src/app/core/factory/sdk/index.test.ts @@ -8,6 +8,7 @@ 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'; const MOCKED_NEW_API = 'https://api.internxt.com'; const MOCKED_PAYMENTS = 'https://payments.internxt.com'; @@ -34,6 +35,16 @@ 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/env.service', () => ({ default: { getVariable: vi.fn((key: string) => { @@ -66,6 +77,14 @@ describe('SdkFactory', () => { SdkFactory.initialize(mockDispatch, mockLocalStorage); }); + describe('initialize', () => { + it('When initialized, then the global retry is set to silent', () => { + expect(HttpClient.enableGlobalRetry).toHaveBeenCalledWith( + expect.objectContaining({ maxRetries: 2, onRetry: expect.any(Function) }), + ); + }); + }); + describe('getNewApiSecurity', () => { it('should return ApiSecurity with token and default unauthorized callback', () => { const mockToken = 'test-token'; diff --git a/src/app/core/factory/sdk/index.ts b/src/app/core/factory/sdk/index.ts index 997ee8d52..905edfe31 100644 --- a/src/app/core/factory/sdk/index.ts +++ b/src/app/core/factory/sdk/index.ts @@ -11,6 +11,13 @@ 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 { retryStrategies } from './retryStrategies'; + +const SdkClient = { + Storage: 'Storage', + Share: 'Share', +} as const; export class SdkFactory { private static sdk: { @@ -30,6 +37,8 @@ export class SdkFactory { localStorage, newApiInstance: new SdkFactory(envService.getVariable('newApi')), }; + + HttpClient.enableGlobalRetry(retryStrategies.silent()); } public static getNewApiInstance(): SdkFactory { @@ -57,7 +66,10 @@ export class SdkFactory { const apiUrl = this.getApiUrl(); const appDetails = SdkFactory.getAppDetails(); const apiSecurity = this.getNewApiSecurity(); - return Storage.client(apiUrl, appDetails, apiSecurity); + return Storage.client(apiUrl, appDetails, { + ...apiSecurity, + retryOptions: retryStrategies.withUserNotification(SdkClient.Storage), + }); } public createWorkspacesClient(): Workspaces { @@ -71,7 +83,10 @@ export class SdkFactory { const apiUrl = this.getApiUrl(); const appDetails = this.getAppDetailsWithHeaders(captchaToken); const apiSecurity = this.getNewApiSecurity(); - return Share.client(apiUrl, appDetails, apiSecurity); + return Share.client(apiUrl, appDetails, { + ...apiSecurity, + retryOptions: retryStrategies.withUserNotification(SdkClient.Share), + }); } public createTrashClient(): Trash { 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..c1fa107de --- /dev/null +++ b/src/app/core/factory/sdk/retryStrategies.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { RetryOptions } from '@internxt/sdk/dist/shared'; +import { retryStrategies, resetToastCooldown } from './retryStrategies'; +import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; +import { hasElapsed } from 'services/date.service'; + +vi.mock('services/date.service', () => ({ + hasElapsed: vi.fn(), +})); + +vi.mock('i18next', () => ({ + t: vi.fn((key: string) => key), +})); + +const getOnRetry = (options: RetryOptions): ((attempt: number, delay: number) => void) => { + if (!options.onRetry) throw new Error('onRetry callback was not provided'); + return options.onRetry; +}; + +const triggerRetryAndVerifyToastShown = (onRetry: (attempt: number, delay: number) => void): void => { + onRetry(1, 1000); + expect(notificationsService.show).toHaveBeenCalledTimes(1); + vi.mocked(notificationsService.show).mockClear(); +}; + +describe('retryStrategies', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(notificationsService, 'show'); + resetToastCooldown(); + }); + + describe('silent', () => { + it('When a silent strategy is created, then it retries up to 2 times', () => { + const options = retryStrategies.silent('Test'); + + expect(options.maxRetries).toBe(2); + }); + + it('When a request is retried, then it logs a warning without notifying the user', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const options = retryStrategies.silent('Test'); + + getOnRetry(options)(1, 1000); + + expect(warnSpy).toHaveBeenCalledWith('[SDK] Test retry attempt 1, waiting 1000ms'); + expect(notificationsService.show).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + }); + + describe('withUserNotification', () => { + it('When a notification strategy is created, then it retries up to 5 times', () => { + const options = retryStrategies.withUserNotification('Test'); + + expect(options.maxRetries).toBe(5); + }); + + it('When a request is retried, then it shows a warning to the user', () => { + vi.mocked(hasElapsed).mockReturnValue(true); + const options = retryStrategies.withUserNotification('Test'); + + getOnRetry(options)(1, 1000); + + expect(notificationsService.show).toHaveBeenCalledWith(expect.objectContaining({ type: ToastType.Warning })); + }); + + it('When a request is retried but a warning was recently shown, then no new warning is displayed', () => { + const onRetry = getOnRetry(retryStrategies.withUserNotification('Test')); + triggerRetryAndVerifyToastShown(onRetry); + + vi.mocked(hasElapsed).mockReturnValue(false); + onRetry(2, 1000); + + expect(notificationsService.show).not.toHaveBeenCalled(); + }); + + it('When a request is retried after enough time has passed, then a new warning is shown', () => { + const onRetry = getOnRetry(retryStrategies.withUserNotification('Test')); + triggerRetryAndVerifyToastShown(onRetry); + + vi.mocked(hasElapsed).mockReturnValue(true); + onRetry(2, 1000); + + expect(notificationsService.show).toHaveBeenCalledWith(expect.objectContaining({ type: ToastType.Warning })); + }); + + it('When different clients are retried, then they share the same warning cooldown', () => { + const onRetryA = getOnRetry(retryStrategies.withUserNotification('A')); + const onRetryB = getOnRetry(retryStrategies.withUserNotification('B')); + triggerRetryAndVerifyToastShown(onRetryA); + + vi.mocked(hasElapsed).mockReturnValue(false); + onRetryB(1, 1000); + + expect(notificationsService.show).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/core/factory/sdk/retryStrategies.ts b/src/app/core/factory/sdk/retryStrategies.ts new file mode 100644 index 000000000..9a2297bf2 --- /dev/null +++ b/src/app/core/factory/sdk/retryStrategies.ts @@ -0,0 +1,39 @@ +import { RetryOptions } from '@internxt/sdk/dist/shared'; +import dayjs, { Dayjs } from 'dayjs'; +import { hasElapsed } from 'services/date.service'; +import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; +import { t } from 'i18next'; + +const RATE_LIMIT_TOAST_COOLDOWN_MINUTES = 1; +let lastRateLimitToastShownAt: Dayjs | null = null; + +export const resetToastCooldown = (): void => { + lastRateLimitToastShownAt = null; +}; + +export const retryStrategies = { + silent: (label = 'Global'): RetryOptions => ({ + maxRetries: 2, + onRetry(attempt, delay) { + console.warn(`[SDK] ${label} retry attempt ${attempt}, waiting ${delay}ms`); + }, + }), + + withUserNotification: (label: string): RetryOptions => ({ + maxRetries: 5, + onRetry(attempt, delay) { + console.warn(`[SDK] ${label} rate limited. Retry attempt ${attempt}, waiting ${delay}ms`); + const isToastOnCooldown = + lastRateLimitToastShownAt && + !hasElapsed(lastRateLimitToastShownAt, RATE_LIMIT_TOAST_COOLDOWN_MINUTES, 'minute'); + if (!isToastOnCooldown) { + lastRateLimitToastShownAt = dayjs(); + notificationsService.show({ + text: t('sdk.rateLimitToast'), + type: ToastType.Warning, + duration: 60000, + }); + } + }, + }), +}; 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..15a0b6ae5 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))); }; +export 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; From 3316660172c6b7a69119b46120808f581505e313 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 26 Feb 2026 23:53:48 -0400 Subject: [PATCH 2/9] feature: enhance retry strategies with user notifications and configurable max retries --- src/app/core/factory/sdk/index.test.ts | 9 ++- src/app/core/factory/sdk/index.ts | 27 ++++++- .../core/factory/sdk/retryStrategies.test.ts | 74 +++++-------------- src/app/core/factory/sdk/retryStrategies.ts | 32 ++------ 4 files changed, 58 insertions(+), 84 deletions(-) diff --git a/src/app/core/factory/sdk/index.test.ts b/src/app/core/factory/sdk/index.test.ts index eda3f0c85..40787d046 100644 --- a/src/app/core/factory/sdk/index.test.ts +++ b/src/app/core/factory/sdk/index.test.ts @@ -9,6 +9,7 @@ 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 { SILENT_MAX_RETRIES } from './retryStrategies'; const MOCKED_NEW_API = 'https://api.internxt.com'; const MOCKED_PAYMENTS = 'https://payments.internxt.com'; @@ -80,9 +81,15 @@ describe('SdkFactory', () => { describe('initialize', () => { it('When initialized, then the global retry is set to silent', () => { expect(HttpClient.enableGlobalRetry).toHaveBeenCalledWith( - expect.objectContaining({ maxRetries: 2, onRetry: expect.any(Function) }), + expect.objectContaining({ maxRetries: SILENT_MAX_RETRIES, onRetry: expect.any(Function) }), ); }); + + it('When not initialized, then the global retry is not enabled', () => { + vi.clearAllMocks(); + + expect(HttpClient.enableGlobalRetry).not.toHaveBeenCalled(); + }); }); describe('getNewApiSecurity', () => { diff --git a/src/app/core/factory/sdk/index.ts b/src/app/core/factory/sdk/index.ts index 905edfe31..4dae63011 100644 --- a/src/app/core/factory/sdk/index.ts +++ b/src/app/core/factory/sdk/index.ts @@ -12,7 +12,28 @@ 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 { retryStrategies } from './retryStrategies'; +import dayjs, { Dayjs } from 'dayjs'; +import { hasElapsed } 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 && !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', @@ -68,7 +89,7 @@ export class SdkFactory { const apiSecurity = this.getNewApiSecurity(); return Storage.client(apiUrl, appDetails, { ...apiSecurity, - retryOptions: retryStrategies.withUserNotification(SdkClient.Storage), + retryOptions: retryStrategies.withUserNotification(SdkClient.Storage, notifyUserWithCooldown), }); } @@ -85,7 +106,7 @@ export class SdkFactory { const apiSecurity = this.getNewApiSecurity(); return Share.client(apiUrl, appDetails, { ...apiSecurity, - retryOptions: retryStrategies.withUserNotification(SdkClient.Share), + retryOptions: retryStrategies.withUserNotification(SdkClient.Share, notifyUserWithCooldown), }); } diff --git a/src/app/core/factory/sdk/retryStrategies.test.ts b/src/app/core/factory/sdk/retryStrategies.test.ts index c1fa107de..e65069947 100644 --- a/src/app/core/factory/sdk/retryStrategies.test.ts +++ b/src/app/core/factory/sdk/retryStrategies.test.ts @@ -1,99 +1,61 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { RetryOptions } from '@internxt/sdk/dist/shared'; -import { retryStrategies, resetToastCooldown } from './retryStrategies'; -import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; -import { hasElapsed } from 'services/date.service'; - -vi.mock('services/date.service', () => ({ - hasElapsed: vi.fn(), -})); - -vi.mock('i18next', () => ({ - t: vi.fn((key: string) => key), -})); +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; }; -const triggerRetryAndVerifyToastShown = (onRetry: (attempt: number, delay: number) => void): void => { - onRetry(1, 1000); - expect(notificationsService.show).toHaveBeenCalledTimes(1); - vi.mocked(notificationsService.show).mockClear(); -}; - describe('retryStrategies', () => { beforeEach(() => { vi.clearAllMocks(); - vi.spyOn(notificationsService, 'show'); - resetToastCooldown(); }); describe('silent', () => { - it('When a silent strategy is created, then it retries up to 2 times', () => { + it('When a silent strategy is created, then it retries up to the configured max', () => { const options = retryStrategies.silent('Test'); - expect(options.maxRetries).toBe(2); + 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(notificationsService.show).not.toHaveBeenCalled(); + expect(notifyUser).not.toHaveBeenCalled(); warnSpy.mockRestore(); }); }); describe('withUserNotification', () => { - it('When a notification strategy is created, then it retries up to 5 times', () => { - const options = retryStrategies.withUserNotification('Test'); + 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(5); + expect(options.maxRetries).toBe(USER_NOTIFICATION_MAX_RETRIES); }); - it('When a request is retried, then it shows a warning to the user', () => { - vi.mocked(hasElapsed).mockReturnValue(true); - const options = retryStrategies.withUserNotification('Test'); + 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(notificationsService.show).toHaveBeenCalledWith(expect.objectContaining({ type: ToastType.Warning })); - }); - - it('When a request is retried but a warning was recently shown, then no new warning is displayed', () => { - const onRetry = getOnRetry(retryStrategies.withUserNotification('Test')); - triggerRetryAndVerifyToastShown(onRetry); - - vi.mocked(hasElapsed).mockReturnValue(false); - onRetry(2, 1000); - - expect(notificationsService.show).not.toHaveBeenCalled(); + expect(notifyUser).toHaveBeenCalledOnce(); }); - it('When a request is retried after enough time has passed, then a new warning is shown', () => { - const onRetry = getOnRetry(retryStrategies.withUserNotification('Test')); - triggerRetryAndVerifyToastShown(onRetry); - - vi.mocked(hasElapsed).mockReturnValue(true); - onRetry(2, 1000); - - expect(notificationsService.show).toHaveBeenCalledWith(expect.objectContaining({ type: ToastType.Warning })); - }); - - it('When different clients are retried, then they share the same warning cooldown', () => { - const onRetryA = getOnRetry(retryStrategies.withUserNotification('A')); - const onRetryB = getOnRetry(retryStrategies.withUserNotification('B')); - triggerRetryAndVerifyToastShown(onRetryA); + 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()); - vi.mocked(hasElapsed).mockReturnValue(false); - onRetryB(1, 1000); + getOnRetry(options)(1, 1000); - expect(notificationsService.show).not.toHaveBeenCalled(); + 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 index 9a2297bf2..4c7bdf148 100644 --- a/src/app/core/factory/sdk/retryStrategies.ts +++ b/src/app/core/factory/sdk/retryStrategies.ts @@ -1,39 +1,23 @@ import { RetryOptions } from '@internxt/sdk/dist/shared'; -import dayjs, { Dayjs } from 'dayjs'; -import { hasElapsed } from 'services/date.service'; -import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; -import { t } from 'i18next'; -const RATE_LIMIT_TOAST_COOLDOWN_MINUTES = 1; -let lastRateLimitToastShownAt: Dayjs | null = null; +export const SILENT_MAX_RETRIES = 2; +export const USER_NOTIFICATION_MAX_RETRIES = 5; -export const resetToastCooldown = (): void => { - lastRateLimitToastShownAt = null; -}; +export type NotifyUserCallback = () => void; export const retryStrategies = { silent: (label = 'Global'): RetryOptions => ({ - maxRetries: 2, + maxRetries: SILENT_MAX_RETRIES, onRetry(attempt, delay) { console.warn(`[SDK] ${label} retry attempt ${attempt}, waiting ${delay}ms`); }, }), - withUserNotification: (label: string): RetryOptions => ({ - maxRetries: 5, + withUserNotification: (label: string, notifyUser: NotifyUserCallback): RetryOptions => ({ + maxRetries: USER_NOTIFICATION_MAX_RETRIES, onRetry(attempt, delay) { - console.warn(`[SDK] ${label} rate limited. Retry attempt ${attempt}, waiting ${delay}ms`); - const isToastOnCooldown = - lastRateLimitToastShownAt && - !hasElapsed(lastRateLimitToastShownAt, RATE_LIMIT_TOAST_COOLDOWN_MINUTES, 'minute'); - if (!isToastOnCooldown) { - lastRateLimitToastShownAt = dayjs(); - notificationsService.show({ - text: t('sdk.rateLimitToast'), - type: ToastType.Warning, - duration: 60000, - }); - } + console.warn(`[SDK] ${label} retry attempt ${attempt}, waiting ${delay}ms`); + notifyUser(); }, }), }; From 8c7d129e5e3f843ba51c878c9221bec144c1e960 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Sun, 1 Mar 2026 21:09:17 -0400 Subject: [PATCH 3/9] feat: add notification handling for retry cooldowns in SdkFactory tests --- src/app/core/factory/sdk/index.test.ts | 71 +++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/src/app/core/factory/sdk/index.test.ts b/src/app/core/factory/sdk/index.test.ts index 40787d046..acf001cde 100644 --- a/src/app/core/factory/sdk/index.test.ts +++ b/src/app/core/factory/sdk/index.test.ts @@ -4,12 +4,14 @@ import { LocalStorageService } from 'services/local-storage.service'; import { userThunks } from '../../../store/slices/user'; import { Workspace } from '../../types'; import { STORAGE_KEYS } from 'services/storage-keys'; -import { Share, Users } from '@internxt/sdk/dist/drive'; +import { Share, Storage, 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 { SILENT_MAX_RETRIES } from './retryStrategies'; +import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; +import { hasElapsed } from 'services/date.service'; const MOCKED_NEW_API = 'https://api.internxt.com'; const MOCKED_PAYMENTS = 'https://payments.internxt.com'; @@ -22,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', () => ({ @@ -46,6 +60,10 @@ vi.mock('i18next', () => ({ t: vi.fn((key: string) => key), })); +vi.mock('services/date.service', () => ({ + hasElapsed: vi.fn().mockReturnValue(true), +})); + vi.mock('services/env.service', () => ({ default: { getVariable: vi.fn((key: string) => { @@ -67,6 +85,21 @@ describe('SdkFactory', () => { let mockDispatch: any; let mockLocalStorage: LocalStorageService; + const getNotifyCallback = () => { + 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(); + + const callArgs = vi.mocked(Storage.client).mock.calls[0]; + const onRetry = callArgs[2]?.retryOptions?.onRetry as (attempt: number, delay: number) => void; + return onRetry; + }; + beforeEach(() => { vi.clearAllMocks(); mockDispatch = vi.fn(); @@ -391,5 +424,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(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(hasElapsed).mockReturnValueOnce(true); + onRetry(2, 2000); + expect(notificationsService.show).toHaveBeenCalledTimes(2); + }); + }); }); }); From c40281b2d066e60d8d1a6f4d0a36d4d8d4835798 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 3 Mar 2026 09:11:55 -0400 Subject: [PATCH 4/9] refactor: update date service import and usage in SdkFactory --- src/app/core/factory/sdk/index.test.ts | 10 ++++++---- src/app/core/factory/sdk/index.ts | 4 ++-- src/services/date.service.ts | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/app/core/factory/sdk/index.test.ts b/src/app/core/factory/sdk/index.test.ts index acf001cde..4fe71557b 100644 --- a/src/app/core/factory/sdk/index.test.ts +++ b/src/app/core/factory/sdk/index.test.ts @@ -11,7 +11,7 @@ import { Location } from '@internxt/sdk'; import { HttpClient } from '@internxt/sdk/dist/shared/http/client'; import { SILENT_MAX_RETRIES } from './retryStrategies'; import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; -import { hasElapsed } from 'services/date.service'; +import dateService from 'services/date.service'; const MOCKED_NEW_API = 'https://api.internxt.com'; const MOCKED_PAYMENTS = 'https://payments.internxt.com'; @@ -61,7 +61,9 @@ vi.mock('i18next', () => ({ })); vi.mock('services/date.service', () => ({ - hasElapsed: vi.fn().mockReturnValue(true), + default: { + hasElapsed: vi.fn().mockReturnValue(true), + }, })); vi.mock('services/env.service', () => ({ @@ -444,7 +446,7 @@ describe('SdkFactory', () => { onRetry(1, 1000); expect(notificationsService.show).toHaveBeenCalledTimes(1); - vi.mocked(hasElapsed).mockReturnValueOnce(false); + vi.mocked(dateService.hasElapsed).mockReturnValueOnce(false); onRetry(2, 2000); expect(notificationsService.show).toHaveBeenCalledTimes(1); }); @@ -455,7 +457,7 @@ describe('SdkFactory', () => { onRetry(1, 1000); expect(notificationsService.show).toHaveBeenCalledTimes(1); - vi.mocked(hasElapsed).mockReturnValueOnce(true); + 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 4dae63011..88e1dbe39 100644 --- a/src/app/core/factory/sdk/index.ts +++ b/src/app/core/factory/sdk/index.ts @@ -13,7 +13,7 @@ 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 { hasElapsed } from 'services/date.service'; +import dateService from 'services/date.service'; import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; import { t } from 'i18next'; import { retryStrategies, NotifyUserCallback } from './retryStrategies'; @@ -24,7 +24,7 @@ let lastRetryToastShownAt: Dayjs | null = null; const notifyUserWithCooldown: NotifyUserCallback = () => { const isToastOnCooldown = - lastRetryToastShownAt && !hasElapsed(lastRetryToastShownAt, RETRY_TOAST_COOLDOWN_MINUTES, 'minute'); + lastRetryToastShownAt && !dateService.hasElapsed(lastRetryToastShownAt, RETRY_TOAST_COOLDOWN_MINUTES, 'minute'); if (!isToastOnCooldown) { lastRetryToastShownAt = dayjs(); notificationsService.show({ diff --git a/src/services/date.service.ts b/src/services/date.service.ts index 15a0b6ae5..0bf6beaf6 100644 --- a/src/services/date.service.ts +++ b/src/services/date.service.ts @@ -36,7 +36,7 @@ const getHoursUntilExpiration = (expiresAt: Date | string): number => { return Math.max(0, Math.ceil(dayjs(expiresAt).diff(dayjs(), 'hour', true))); }; -export const hasElapsed = (since: Dayjs, amount: number, unit: dayjs.ManipulateType): boolean => +const hasElapsed = (since: Dayjs, amount: number, unit: dayjs.ManipulateType): boolean => dayjs().diff(since, unit) >= amount; const dateService = { From f56bd8386817115b07bba165be5debf359afb2e0 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 3 Mar 2026 11:31:12 -0400 Subject: [PATCH 5/9] feat: update global retry initialization to enable silent strategy and prevent multiple calls --- src/app/core/factory/sdk/index.test.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/app/core/factory/sdk/index.test.ts b/src/app/core/factory/sdk/index.test.ts index 4fe71557b..63bfc8dae 100644 --- a/src/app/core/factory/sdk/index.test.ts +++ b/src/app/core/factory/sdk/index.test.ts @@ -114,14 +114,25 @@ describe('SdkFactory', () => { }); describe('initialize', () => { - it('When initialized, then the global retry is set to silent', () => { + it('When initialized, then the global retry is enabled with silent strategy', () => { + expect(HttpClient.enableGlobalRetry).toHaveBeenCalledTimes(1); expect(HttpClient.enableGlobalRetry).toHaveBeenCalledWith( expect.objectContaining({ maxRetries: SILENT_MAX_RETRIES, onRetry: expect.any(Function) }), ); }); - it('When not initialized, then the global retry is not enabled', () => { - vi.clearAllMocks(); + 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(); }); From 6d82edc534ad1594ea77ac8e3e445d681789cb81 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Fri, 6 Mar 2026 00:36:37 -0400 Subject: [PATCH 6/9] feat: update global retry to use user notification strategy for storage and share clients --- src/app/core/factory/sdk/index.test.ts | 23 +++++++---------------- src/app/core/factory/sdk/index.ts | 12 +++--------- 2 files changed, 10 insertions(+), 25 deletions(-) diff --git a/src/app/core/factory/sdk/index.test.ts b/src/app/core/factory/sdk/index.test.ts index 63bfc8dae..38bd88c4f 100644 --- a/src/app/core/factory/sdk/index.test.ts +++ b/src/app/core/factory/sdk/index.test.ts @@ -4,12 +4,12 @@ import { LocalStorageService } from 'services/local-storage.service'; import { userThunks } from '../../../store/slices/user'; import { Workspace } from '../../types'; import { STORAGE_KEYS } from 'services/storage-keys'; -import { Share, Storage, Users } from '@internxt/sdk/dist/drive'; +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 { SILENT_MAX_RETRIES } from './retryStrategies'; +import { USER_NOTIFICATION_MAX_RETRIES } from './retryStrategies'; import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; import dateService from 'services/date.service'; @@ -88,18 +88,9 @@ describe('SdkFactory', () => { let mockLocalStorage: LocalStorageService; const getNotifyCallback = () => { - 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(); - - const callArgs = vi.mocked(Storage.client).mock.calls[0]; - const onRetry = callArgs[2]?.retryOptions?.onRetry as (attempt: number, delay: number) => void; - return onRetry; + const callArgs = vi.mocked(HttpClient.enableGlobalRetry).mock.calls[0]; + const retryOptions = callArgs[0] as { onRetry: (attempt: number, delay: number) => void }; + return retryOptions.onRetry; }; beforeEach(() => { @@ -114,10 +105,10 @@ describe('SdkFactory', () => { }); describe('initialize', () => { - it('When initialized, then the global retry is enabled with silent strategy', () => { + 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: SILENT_MAX_RETRIES, onRetry: expect.any(Function) }), + expect.objectContaining({ maxRetries: USER_NOTIFICATION_MAX_RETRIES, onRetry: expect.any(Function) }), ); }); diff --git a/src/app/core/factory/sdk/index.ts b/src/app/core/factory/sdk/index.ts index 88e1dbe39..21dd789cf 100644 --- a/src/app/core/factory/sdk/index.ts +++ b/src/app/core/factory/sdk/index.ts @@ -59,7 +59,7 @@ export class SdkFactory { newApiInstance: new SdkFactory(envService.getVariable('newApi')), }; - HttpClient.enableGlobalRetry(retryStrategies.silent()); + HttpClient.enableGlobalRetry(retryStrategies.withUserNotification(SdkClient.Storage, notifyUserWithCooldown)); } public static getNewApiInstance(): SdkFactory { @@ -87,10 +87,7 @@ export class SdkFactory { const apiUrl = this.getApiUrl(); const appDetails = SdkFactory.getAppDetails(); const apiSecurity = this.getNewApiSecurity(); - return Storage.client(apiUrl, appDetails, { - ...apiSecurity, - retryOptions: retryStrategies.withUserNotification(SdkClient.Storage, notifyUserWithCooldown), - }); + return Storage.client(apiUrl, appDetails, apiSecurity); } public createWorkspacesClient(): Workspaces { @@ -104,10 +101,7 @@ export class SdkFactory { const apiUrl = this.getApiUrl(); const appDetails = this.getAppDetailsWithHeaders(captchaToken); const apiSecurity = this.getNewApiSecurity(); - return Share.client(apiUrl, appDetails, { - ...apiSecurity, - retryOptions: retryStrategies.withUserNotification(SdkClient.Share, notifyUserWithCooldown), - }); + return Share.client(apiUrl, appDetails, apiSecurity); } public createTrashClient(): Trash { From ef1934d1dab91edca06dee451ed0ea3c191283fc Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Mon, 9 Mar 2026 11:55:19 -0400 Subject: [PATCH 7/9] refactor: replace global retry with per-client retry to prevent gateway upload conflicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global HttpClient.enableGlobalRetry was applying retry to all SDK clients including the gateway Network client, which broke multi-step uploads (start → upload parts → finish) by retrying individual steps independently. Moving retryOptions into ApiSecurity enables retry only for drive API clients (Storage, Share, Users, etc.) while leaving the gateway client retry-free — uploads already have operation-level retry. --- src/app/core/factory/sdk/index.test.ts | 42 ++++++++++++-------------- src/app/core/factory/sdk/index.ts | 5 ++- 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/src/app/core/factory/sdk/index.test.ts b/src/app/core/factory/sdk/index.test.ts index 38bd88c4f..d68c83936 100644 --- a/src/app/core/factory/sdk/index.test.ts +++ b/src/app/core/factory/sdk/index.test.ts @@ -4,11 +4,10 @@ import { LocalStorageService } from 'services/local-storage.service'; import { userThunks } from '../../../store/slices/user'; import { Workspace } from '../../types'; import { STORAGE_KEYS } from 'services/storage-keys'; -import { Share, Users } from '@internxt/sdk/dist/drive'; +import { Share, Storage, 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'; @@ -50,12 +49,6 @@ 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), })); @@ -88,9 +81,18 @@ describe('SdkFactory', () => { 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; + 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(); + + const callArgs = vi.mocked(Storage.client).mock.calls[0]; + const apiSecurity = callArgs[2] as { retryOptions: { onRetry: (attempt: number, delay: number) => void } }; + return apiSecurity.retryOptions.onRetry; }; beforeEach(() => { @@ -105,16 +107,7 @@ describe('SdkFactory', () => { }); 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(); - + it('When a storage client is created, then retryOptions with user notification strategy is passed', () => { vi.spyOn(mockLocalStorage, 'getWorkspace').mockReturnValue(Workspace.Individuals); vi.spyOn(mockLocalStorage, 'get').mockImplementation((key: string) => { if (key === 'xNewToken') return 'test-token'; @@ -123,9 +116,12 @@ describe('SdkFactory', () => { const instance = SdkFactory.getNewApiInstance(); instance.createNewStorageClient(); - instance.createShareClient(); - expect(HttpClient.enableGlobalRetry).not.toHaveBeenCalled(); + const callArgs = vi.mocked(Storage.client).mock.calls[0]; + const apiSecurity = callArgs[2]; + expect(apiSecurity.retryOptions).toEqual( + expect.objectContaining({ maxRetries: USER_NOTIFICATION_MAX_RETRIES, onRetry: expect.any(Function) }), + ); }); }); diff --git a/src/app/core/factory/sdk/index.ts b/src/app/core/factory/sdk/index.ts index 21dd789cf..8585bc7fb 100644 --- a/src/app/core/factory/sdk/index.ts +++ b/src/app/core/factory/sdk/index.ts @@ -11,7 +11,6 @@ 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'; @@ -58,8 +57,6 @@ export class SdkFactory { localStorage, newApiInstance: new SdkFactory(envService.getVariable('newApi')), }; - - HttpClient.enableGlobalRetry(retryStrategies.withUserNotification(SdkClient.Storage, notifyUserWithCooldown)); } public static getNewApiInstance(): SdkFactory { @@ -158,6 +155,7 @@ export class SdkFactory { return { token: this.getNewToken(workspace), workspaceToken, + retryOptions: retryStrategies.withUserNotification(SdkClient.Storage, notifyUserWithCooldown), unauthorizedCallback: unauthorizedCallback ?? (() => { @@ -170,6 +168,7 @@ export class SdkFactory { const token = this.getNewToken(Workspace.Individuals); return { token, + retryOptions: retryStrategies.withUserNotification(SdkClient.Storage, notifyUserWithCooldown), unauthorizedCallback: () => { SdkFactory.sdk.dispatch(userThunks.logoutThunk()); }, From d7022fb3450791fce909fb3fa48e8b07a998dcd8 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Mon, 9 Mar 2026 23:00:22 -0400 Subject: [PATCH 8/9] feat: restore global retry and enable 429 backoff in upload workers Previously, per-client retryOptions only covered Storage/Share clients in the main thread, leaving gateway startUpload/finishUpload calls in Web Workers without 429 handling. This restores HttpClient.enableGlobalRetry in both the main thread and upload worker context. --- src/upload.worker.ts | 3 +++ 1 file changed, 3 insertions(+) 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); From ebfd104f310394e7ed1e9ac625bac8f5ed3ebbed Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Mon, 9 Mar 2026 23:14:48 -0400 Subject: [PATCH 9/9] Revert " refactor: replace global retry with per-client retry to prevent gateway upload conflicts" This reverts commit c0f0d74f41ec1452bcec0ca12150498a1a7b4d27. --- src/app/core/factory/sdk/index.test.ts | 42 ++++++++++++++------------ src/app/core/factory/sdk/index.ts | 5 +-- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/src/app/core/factory/sdk/index.test.ts b/src/app/core/factory/sdk/index.test.ts index d68c83936..38bd88c4f 100644 --- a/src/app/core/factory/sdk/index.test.ts +++ b/src/app/core/factory/sdk/index.test.ts @@ -4,10 +4,11 @@ import { LocalStorageService } from 'services/local-storage.service'; import { userThunks } from '../../../store/slices/user'; import { Workspace } from '../../types'; import { STORAGE_KEYS } from 'services/storage-keys'; -import { Share, Storage, Users } from '@internxt/sdk/dist/drive'; +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'; @@ -49,6 +50,12 @@ 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), })); @@ -81,18 +88,9 @@ describe('SdkFactory', () => { let mockLocalStorage: LocalStorageService; const getNotifyCallback = () => { - 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(); - - const callArgs = vi.mocked(Storage.client).mock.calls[0]; - const apiSecurity = callArgs[2] as { retryOptions: { onRetry: (attempt: number, delay: number) => void } }; - return apiSecurity.retryOptions.onRetry; + const callArgs = vi.mocked(HttpClient.enableGlobalRetry).mock.calls[0]; + const retryOptions = callArgs[0] as { onRetry: (attempt: number, delay: number) => void }; + return retryOptions.onRetry; }; beforeEach(() => { @@ -107,7 +105,16 @@ describe('SdkFactory', () => { }); describe('initialize', () => { - it('When a storage client is created, then retryOptions with user notification strategy is passed', () => { + 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'; @@ -116,12 +123,9 @@ describe('SdkFactory', () => { const instance = SdkFactory.getNewApiInstance(); instance.createNewStorageClient(); + instance.createShareClient(); - const callArgs = vi.mocked(Storage.client).mock.calls[0]; - const apiSecurity = callArgs[2]; - expect(apiSecurity.retryOptions).toEqual( - expect.objectContaining({ maxRetries: USER_NOTIFICATION_MAX_RETRIES, onRetry: expect.any(Function) }), - ); + expect(HttpClient.enableGlobalRetry).not.toHaveBeenCalled(); }); }); diff --git a/src/app/core/factory/sdk/index.ts b/src/app/core/factory/sdk/index.ts index 8585bc7fb..21dd789cf 100644 --- a/src/app/core/factory/sdk/index.ts +++ b/src/app/core/factory/sdk/index.ts @@ -11,6 +11,7 @@ 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'; @@ -57,6 +58,8 @@ export class SdkFactory { localStorage, newApiInstance: new SdkFactory(envService.getVariable('newApi')), }; + + HttpClient.enableGlobalRetry(retryStrategies.withUserNotification(SdkClient.Storage, notifyUserWithCooldown)); } public static getNewApiInstance(): SdkFactory { @@ -155,7 +158,6 @@ export class SdkFactory { return { token: this.getNewToken(workspace), workspaceToken, - retryOptions: retryStrategies.withUserNotification(SdkClient.Storage, notifyUserWithCooldown), unauthorizedCallback: unauthorizedCallback ?? (() => { @@ -168,7 +170,6 @@ export class SdkFactory { const token = this.getNewToken(Workspace.Individuals); return { token, - retryOptions: retryStrategies.withUserNotification(SdkClient.Storage, notifyUserWithCooldown), unauthorizedCallback: () => { SdkFactory.sdk.dispatch(userThunks.logoutThunk()); },