diff --git a/package.json b/package.json index 49a3e9e4e..207d056e3 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "@iconscout/react-unicons": "^1.1.6", "@internxt/css-config": "1.1.0", "@internxt/lib": "1.4.1", - "@internxt/sdk": "=1.12.0", + "@internxt/sdk": "=1.12.2", "@internxt/ui": "0.1.1", "@phosphor-icons/react": "^2.1.7", "@popperjs/core": "^2.11.6", diff --git a/src/app/core/constants.ts b/src/app/core/constants.ts index ec48fb17f..c6da47f11 100644 --- a/src/app/core/constants.ts +++ b/src/app/core/constants.ts @@ -3,6 +3,7 @@ export const HTTP_CODES = { MAX_SPACE_USED: 420, FORBIDDEN: 403, NOT_FOUND: 404, + TOO_MANY_REQUESTS: 429, }; export enum ErrorMessages { ServerUnavailable = 'Server Unavailable', diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index a24474fdb..235c56374 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -1150,7 +1150,9 @@ "copy-to-clipboard": "Link in die Zwischenablage kopiert", "link-updated": "Linkeinstellungen aktualisiert", "link-deleted": "Link gelöscht", - "links-deleted": "Link(s) gelöscht" + "links-deleted": "Link(s) gelöscht", + "error-deleting-links": "Fehler beim Löschen der Links", + "rate-limit-retry": "Dies dauert länger als erwartet. Bitte warten..." } }, "trash": { diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index a76f386eb..7384f58e5 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -1235,7 +1235,8 @@ "link-updated": "Link settings updated", "link-deleted": "Link deleted", "links-deleted": "Link(s) deleted", - "error-deleting-links": "An error occurred deleting links" + "error-deleting-links": "An error occurred deleting links", + "rate-limit-retry": "This is taking longer than expected. Please wait..." } }, "trash": { diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json index 3d38f0310..68be7fc74 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -1212,7 +1212,8 @@ "link-updated": "Link settings updated", "link-deleted": "Enlace eliminado", "links-deleted": "Enlaces eliminados", - "error-deleting-links": "Se produjo un error al eliminar enlaces compartidos" + "error-deleting-links": "Se produjo un error al eliminar enlaces compartidos", + "rate-limit-retry": "Esto está tardando más de lo esperado. Por favor, espera..." } }, "trash": { diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json index 4c0592294..b7e839084 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -1163,7 +1163,8 @@ "link-updated": "Mise à jour des paramètres de lien", "link-deleted": "Lien supprimé", "links-deleted": "Lien(s) supprimé", - "error-deleting-links": "Une erreur s'est produite lors de la suppression des liens partagés" + "error-deleting-links": "Une erreur s'est produite lors de la suppression des liens partagés", + "rate-limit-retry": "Cela prend plus de temps que prévu. Veuillez patienter..." } }, "trash": { diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json index 2aaa139db..6dca6c6ba 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -1270,7 +1270,8 @@ "link-updated": "Impostazioni di collegamento aggiornate", "link-deleted": "Link rimosso", "links-deleted": "Link(s) rimossi", - "error-deleting-links": "Si è verificato un errore durante l'eliminazione dei link condivisi" + "error-deleting-links": "Si è verificato un errore durante l'eliminazione dei link condivisi", + "rate-limit-retry": "Ci sta mettendo più tempo del previsto. Attendere prego..." } }, "trash": { diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index b5197f308..a8abaea64 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -1176,7 +1176,8 @@ "link-updated": "Настройки ссылки обновлены", "link-deleted": "Ссылка удалена", "links-deleted": "Ссылка(и) удалена(ы)", - "error-deleting-links": "Возникла ошибка при удалении общих ссылок" + "error-deleting-links": "Возникла ошибка при удалении общих ссылок", + "rate-limit-retry": "Это занимает больше времени, чем ожидалось. Пожалуйста, подождите..." } }, "trash": { diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json index 41d320132..cca7583b1 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -1165,7 +1165,8 @@ "link-updated": "鏈接設置已更新", "link-deleted": "鏈接已刪除", "links-deleted": "鏈接已刪除", - "error-deleting-links": "刪除鏈接時出錯" + "error-deleting-links": "刪除鏈接時出錯", + "rate-limit-retry": "這比預期的時間要長。請稍候..." } }, "trash": { diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json index e6cc7677e..add44259e 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -1200,7 +1200,8 @@ "link-updated": "链接设置已更新", "link-deleted": "链接已删除", "links-deleted": "链接已删除", - "error-deleting-links": "删除共享链接时发生错误" + "error-deleting-links": "删除共享链接时发生错误", + "rate-limit-retry": "这比预期的时间要长。请稍候..." } }, "trash": { diff --git a/src/app/network/retry-with-rate-limit.test.ts b/src/app/network/retry-with-rate-limit.test.ts new file mode 100644 index 000000000..8fdfda075 --- /dev/null +++ b/src/app/network/retry-with-rate-limit.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { retryWithBackoff } from './retry-with-rate-limit'; +import * as timeUtils from 'utils/timeUtils'; + +vi.mock('utils/timeUtils', () => ({ + wait: vi.fn(() => Promise.resolve()), +})); + +const createRateLimitError = (resetDelay: string, additionalHeaders?: Record) => ({ + status: 429, + headers: { 'x-internxt-ratelimit-reset': resetDelay, ...additionalHeaders }, +}); + +const createRateLimitMock = (resetDelay: string, additionalHeaders?: Record) => + vi.fn().mockRejectedValueOnce(createRateLimitError(resetDelay, additionalHeaders)).mockResolvedValueOnce('success'); + +describe('retryWithBackoff', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('when function succeeds immediately then returns result without retry', async () => { + const mockFn = vi.fn().mockResolvedValue('success'); + + const result = await retryWithBackoff(mockFn); + + expect(result).toBe('success'); + expect(mockFn).toHaveBeenCalledTimes(1); + expect(timeUtils.wait).not.toHaveBeenCalled(); + }); + + it('when rate limited then retries with delay from headers', async () => { + const mockFn = createRateLimitMock('5000'); + + const result = await retryWithBackoff(mockFn); + + expect(result).toBe('success'); + expect(mockFn).toHaveBeenCalledTimes(2); + expect(timeUtils.wait).toHaveBeenCalledWith(5000); + }); + + it('when error is not rate limit then throws immediately without retry', async () => { + const mockFn = vi.fn().mockRejectedValue({ status: 500, message: 'Internal Server Error' }); + + await expect(retryWithBackoff(mockFn)).rejects.toEqual({ status: 500, message: 'Internal Server Error' }); + + expect(mockFn).toHaveBeenCalledTimes(1); + expect(timeUtils.wait).not.toHaveBeenCalled(); + }); + + it('when rate limited multiple times then calls onRetry for each retry', async () => { + const error = createRateLimitError('1000'); + const mockFn = vi.fn().mockRejectedValueOnce(error).mockRejectedValueOnce(error).mockResolvedValueOnce('success'); + + const onRetry = vi.fn(); + + const result = await retryWithBackoff(mockFn, { onRetry }); + + expect(result).toBe('success'); + expect(mockFn).toHaveBeenCalledTimes(3); + expect(onRetry).toHaveBeenCalledTimes(2); + expect(onRetry).toHaveBeenNthCalledWith(1, 1, 1000); + expect(onRetry).toHaveBeenNthCalledWith(2, 2, 1000); + }); + + it('when headers missing or invalid then throws error', async () => { + const testCases = [ + { status: 429 }, + { status: 429, headers: {} }, + { status: 429, headers: { 'x-internxt-ratelimit-reset': 'invalid' } }, + ]; + + for (const error of testCases) { + const mockFn = vi.fn().mockRejectedValue(error); + await expect(retryWithBackoff(mockFn)).rejects.toEqual(error); + expect(mockFn).toHaveBeenCalledTimes(1); + vi.clearAllMocks(); + } + }); + + it('when max retries exceeded then throws original error', async () => { + const error = new Error('Rate limit exceeded'); + Object.assign(error, { status: 429, headers: { 'x-internxt-ratelimit-reset': '1000' } }); + const mockFn = vi.fn().mockRejectedValue(error); + + await expect(retryWithBackoff(mockFn, { maxRetries: 2 })).rejects.toThrow('Rate limit exceeded'); + + expect(mockFn).toHaveBeenCalledTimes(3); + expect(timeUtils.wait).toHaveBeenCalledTimes(2); + }); + + it('when error is not an object then throws immediately without retry', async () => { + const testCases = ['string error', null]; + + for (const error of testCases) { + const mockFn = vi.fn().mockRejectedValue(error); + await expect(retryWithBackoff(mockFn)).rejects.toBe(error); + expect(mockFn).toHaveBeenCalledTimes(1); + vi.clearAllMocks(); + } + + expect(timeUtils.wait).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/network/retry-with-rate-limit.ts b/src/app/network/retry-with-rate-limit.ts new file mode 100644 index 000000000..10cb95f55 --- /dev/null +++ b/src/app/network/retry-with-rate-limit.ts @@ -0,0 +1,79 @@ +import { wait } from 'utils/timeUtils'; +import { HTTP_CODES } from 'app/core/constants'; + +export interface RetryOptions { + maxRetries?: number; + onRetry?: (attempt: number, delay: number) => void; +} + +interface ErrorWithStatus { + status?: number; + headers?: Record; +} + +const isErrorWithStatus = (error: unknown): error is ErrorWithStatus => { + return typeof error === 'object' && error !== null; +}; + +const isRateLimitError = (error: unknown): boolean => { + if (!isErrorWithStatus(error)) { + return false; + } + return error.status === HTTP_CODES.TOO_MANY_REQUESTS; +}; + +const extractRetryAfter = (error: ErrorWithStatus): number | undefined => { + const headers = error.headers; + const resetHeader = headers?.['x-internxt-ratelimit-reset']; + if (!resetHeader) { + return undefined; + } + + const resetValueMs = Number.parseInt(resetHeader, 10); + if (Number.isNaN(resetValueMs)) { + return undefined; + } + + return resetValueMs; +}; + +/** + * Retries a function when it encounters a rate limit error (429). + * Uses the retry-after value from the x-internxt-ratelimit-reset header to wait before retrying. + * + * @param fn - The async function to execute with retry logic + * @param options - Configuration options for retry behavior + * @param options.maxRetries - Maximum number of retry attempts (default: 5) + * @param options.onRetry - Optional callback invoked before each retry with attempt number and retry after value + * @returns The result of the function if successful + * @throws The original error if it's not a rate limit error, if max retries exceeded, or if rate limit headers are missing + */ +export const retryWithBackoff = async (fn: () => Promise, options: RetryOptions = {}): Promise => { + const opts = { + maxRetries: 5, + onRetry: () => {}, + ...options, + }; + + for (let attempt = 0; attempt < opts.maxRetries; attempt++) { + try { + return await fn(); + } catch (error: unknown) { + if (!isRateLimitError(error)) { + throw error; + } + + const retryAfter = extractRetryAfter(error as ErrorWithStatus); + + if (!retryAfter) { + throw error; + } + + opts.onRetry(attempt + 1, retryAfter); + + await wait(retryAfter); + } + } + + return await fn(); +}; diff --git a/src/app/share/services/share.service.ts b/src/app/share/services/share.service.ts index 6b76c6fb7..130e0ad54 100644 --- a/src/app/share/services/share.service.ts +++ b/src/app/share/services/share.service.ts @@ -37,6 +37,7 @@ import { AdvancedSharedItem } from '../types'; import { domainManager } from './DomainManager'; import { generateCaptchaToken } from 'utils'; import { copyTextToClipboard } from 'utils/copyToClipboard.utils'; +import { retryWithBackoff } from '../../network/retry-with-rate-limit'; interface CreateShareResponse { created: boolean; @@ -120,15 +121,29 @@ export function getPublicSharedFolderContent( token: string | null, page: number, perPage: number, - code?: string, - orderBy?: 'views:ASC' | 'views:DESC' | 'createdAt:ASC' | 'createdAt:DESC', + options?: { + code?: string; + orderBy?: 'views:ASC' | 'views:DESC' | 'createdAt:ASC' | 'createdAt:DESC'; + onRetry?: (attempt: number, delay: number) => void; + }, ): Promise { - const shareClient = SdkFactory.getNewApiInstance().createShareClient(); - return shareClient - .getPublicSharedFolderContent(sharedFolderId, type, token, page, perPage, code, orderBy) - .catch((error) => { - throw error; - }); + return retryWithBackoff( + async () => { + const shareClient = SdkFactory.getNewApiInstance().createShareClient(); + return shareClient.getPublicSharedFolderContent( + sharedFolderId, + type, + token, + page, + perPage, + options?.code, + options?.orderBy, + ); + }, + { + onRetry: options?.onRetry, + }, + ); } export function deleteShareLink(shareId: string): Promise<{ deleted: boolean; shareId: string }> { @@ -486,22 +501,31 @@ class DirectorySharedFilesIterator implements Iterator { class DirectoryPublicSharedFolderIterator implements Iterator { private page: number; private itemsPerPage: number; - private readonly queryValues: { directoryId: string; resourcesToken?: string }; + private readonly queryValues: { + directoryId: string; + resourcesToken?: string; + onRetry?: (attempt: number, delay: number) => void; + }; - constructor(queryValues: { directoryId: string; resourcesToken?: string }, page?: number, itemsPerPage?: number) { + constructor( + queryValues: { directoryId: string; resourcesToken?: string; onRetry?: (attempt: number, delay: number) => void }, + page?: number, + itemsPerPage?: number, + ) { this.page = page ?? 0; this.itemsPerPage = itemsPerPage ?? 5; this.queryValues = queryValues; } async next() { - const { directoryId, resourcesToken } = this.queryValues; + const { directoryId, resourcesToken, onRetry } = this.queryValues; const items = await getPublicSharedFolderContent( directoryId, 'folders', resourcesToken ?? '', this.page, this.itemsPerPage, + { onRetry }, ); const folders = items.items.map((folder) => ({ ...folder, name: folder?.plainName ?? folder.name })); this.page += 1; @@ -515,10 +539,20 @@ class DirectoryPublicSharedFolderIterator implements Iterator { class DirectoryPublicSharedFilesIterator implements Iterator { private page: number; private itemsPerPage: number; - private readonly queryValues: { directoryId: string; resourcesToken?: string; code?: string }; + private readonly queryValues: { + directoryId: string; + resourcesToken?: string; + code?: string; + onRetry?: (attempt: number, delay: number) => void; + }; constructor( - queryValues: { directoryId: string; resourcesToken?: string; code?: string }, + queryValues: { + directoryId: string; + resourcesToken?: string; + code?: string; + onRetry?: (attempt: number, delay: number) => void; + }, page?: number, itemsPerPage?: number, ) { @@ -528,7 +562,7 @@ class DirectoryPublicSharedFilesIterator implements Iterator { } async next() { - const { directoryId, resourcesToken, code } = this.queryValues; + const { directoryId, resourcesToken, code, onRetry } = this.queryValues; const items = await getPublicSharedFolderContent( directoryId, @@ -536,7 +570,7 @@ class DirectoryPublicSharedFilesIterator implements Iterator { resourcesToken ?? '', this.page, this.itemsPerPage, - code, + { code, onRetry }, ); const files = items.items.map((file) => ({ ...file, name: file?.plainName ?? file.name })); this.page += 1; @@ -657,12 +691,14 @@ export async function downloadPublicSharedFolder({ token, code, incrementItemCount, + onRetry, }: { encryptionKey: string; item; token?: string; code: string; incrementItemCount: () => void; + onRetry?: (attempt: number, delay: number) => void; }): Promise { const initPage = 0; const itemsPerPage = 15; @@ -676,7 +712,7 @@ export async function downloadPublicSharedFolder({ '', 0, 15, - code, + { code, onRetry }, ); if (!credentials) { @@ -685,7 +721,7 @@ export async function downloadPublicSharedFolder({ const createFoldersIterator = (directoryUuid: string, resourcesToken?: string) => { return new DirectoryPublicSharedFolderIterator( - { directoryId: directoryUuid, resourcesToken: resourcesToken ?? token }, + { directoryId: directoryUuid, resourcesToken: resourcesToken ?? token, onRetry }, initPage, itemsPerPage, ); @@ -693,7 +729,7 @@ export async function downloadPublicSharedFolder({ const createFilesIterator = (directoryUuid: string, resourcesToken?: string) => { return new DirectoryPublicSharedFilesIterator( - { directoryId: directoryUuid, resourcesToken: resourcesToken ?? token, code: code }, + { directoryId: directoryUuid, resourcesToken: resourcesToken ?? token, code: code, onRetry }, initPage, itemsPerPage, ); diff --git a/src/views/PublicShared/ShareFolderView.tsx b/src/views/PublicShared/ShareFolderView.tsx index 0813c4d42..806fc7f6c 100644 --- a/src/views/PublicShared/ShareFolderView.tsx +++ b/src/views/PublicShared/ShareFolderView.tsx @@ -139,11 +139,24 @@ export default function ShareFolderView(props: ShareViewProps): JSX.Element { if (folderInfo) { setIsDownloading(true); + let hasShownRateLimitNotification = false; + downloadPublicSharedFolder({ encryptionKey: folderInfo.encryptionKey, item: folderInfo.item, code, incrementItemCount, + onRetry: (attempt, delay) => { + console.warn(`[PUBLIC-SHARED-FOLDER] Retry attempt ${attempt} after ${delay}ms`); + if (!hasShownRateLimitNotification) { + hasShownRateLimitNotification = true; + notificationsService.show({ + text: translate('shared-links.toast.rate-limit-retry'), + type: ToastType.Warning, + duration: Infinity, + }); + } + }, }) .then(() => { updateProgress(1); diff --git a/yarn.lock b/yarn.lock index fd3714a8d..e7ca2c13d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1906,10 +1906,10 @@ version "1.0.2" resolved "https://codeload.github.com/internxt/prettier-config/tar.gz/9fa74e9a2805e1538b50c3809324f1c9d0f3e4f9" -"@internxt/sdk@=1.12.0": - version "1.12.0" - resolved "https://registry.yarnpkg.com/@internxt/sdk/-/sdk-1.12.0.tgz#cf9c2f0ca8864a688e4c161f470e171997bff7bb" - integrity sha512-QrjH2yJP7MjxAVvkOe6quqX7RYzC6e3M0XcXralJEFybDpimJBJbvRTPUe7+9XRQ6gHdmYi1u3ySDVoZyZpkug== +"@internxt/sdk@=1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@internxt/sdk/-/sdk-1.12.2.tgz#8c6b76d8fd314fc0ec09d7a52d14ab1139db5e53" + integrity sha512-7GJQtlg0tmJZa0eJcEznRjUAuoiqZDEtuvCR25/cuSZGrd0mG6MOOfgEMl0vIBzpvPnjrFjCLVSjnmXLkXpHLQ== dependencies: axios "1.13.2" uuid "11.1.0"