From 31e89e9cd1a95940a3d9ee1a221db8e93d53359d Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 3 Feb 2026 00:17:47 -0400 Subject: [PATCH 1/5] feature: Add rate limit retry mechanism with exponential backoff Implement retry utility to handle 429 rate limit errors with configurable backoff strategy, extract rate limit info from response headers, and apply it to public shared folder content requests to improve reliability under rate limiting conditions. --- package.json | 2 +- src/app/network/retry-with-rate-limit.test.ts | 131 ++++++++++++++++++ src/app/network/retry-with-rate-limit.ts | 104 ++++++++++++++ src/app/share/services/share.service.ts | 22 ++- yarn.lock | 8 +- 5 files changed, 256 insertions(+), 11 deletions(-) create mode 100644 src/app/network/retry-with-rate-limit.test.ts create mode 100644 src/app/network/retry-with-rate-limit.ts 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/network/retry-with-rate-limit.test.ts b/src/app/network/retry-with-rate-limit.test.ts new file mode 100644 index 000000000..8a9d9d85a --- /dev/null +++ b/src/app/network/retry-with-rate-limit.test.ts @@ -0,0 +1,131 @@ +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 = (status: number, headers?: Record, message?: string) => ({ + status, + headers, + ...(message && { message }), +}); + +const createRateLimitMock = (resetDelay: string, additionalHeaders?: Record) => + vi + .fn() + .mockRejectedValueOnce(createRateLimitError(429, { 'x-ratelimit-reset': 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 different error formats received then recognizes all as rate limits', async () => { + const testCases = [ + { statusCode: 429, headers: { 'x-ratelimit-reset': '1000' } }, + { response: { status: 429 }, headers: { 'x-ratelimit-reset': '1000' } }, + { message: 'Too Many Requests', headers: { 'x-ratelimit-reset': '1000' } }, + ]; + + for (const errorFormat of testCases) { + const mockFn = vi.fn().mockRejectedValueOnce(errorFormat).mockResolvedValueOnce('success'); + await retryWithBackoff(mockFn); + expect(mockFn).toHaveBeenCalledTimes(2); + vi.clearAllMocks(); + } + }); + + 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 headers provided then extracts info and calls callback', async () => { + const mockFn = createRateLimitMock('10000', { + 'x-ratelimit-limit': '100', + 'x-ratelimit-remaining': '0', + }); + + const onRetry = vi.fn(); + + await retryWithBackoff(mockFn, { onRetry }); + + expect(timeUtils.wait).toHaveBeenCalledWith(10000); + expect(onRetry).toHaveBeenCalledWith(1, 10000, { + retryAfter: 10000, + limit: 100, + remaining: 0, + }); + }); + + it('when headers missing or invalid then uses maxDelay fallback', async () => { + const onRetry = vi.fn(); + + const testCases = [ + { error: { status: 429 }, delay: 30000, onRetry }, + { error: { status: 429, headers: { 'x-ratelimit-reset': 'invalid' } }, delay: 20000 }, + ]; + + for (const { error, delay, onRetry: callback } of testCases) { + const mockFn = vi.fn().mockRejectedValueOnce(error).mockResolvedValueOnce('success'); + const options = callback ? { maxDelay: delay, onRetry: callback } : { maxDelay: delay }; + await retryWithBackoff(mockFn, options); + expect(timeUtils.wait).toHaveBeenCalledWith(delay); + if (callback) { + expect(callback).toHaveBeenCalledWith(1, delay, undefined); + } + 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-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..2d40111aa --- /dev/null +++ b/src/app/network/retry-with-rate-limit.ts @@ -0,0 +1,104 @@ +import { wait } from 'utils/timeUtils'; + +export interface RateLimitInfo { + retryAfter: number; + limit?: number; + remaining?: number; +} + +export interface RetryOptions { + maxRetries?: number; + maxDelay?: number; + onRetry?: (attempt: number, delay: number, rateLimitInfo?: RateLimitInfo) => void; +} + +interface ErrorWithStatus { + status?: number; + statusCode?: number; + message?: string; + response?: { + status?: number; + }; + headers?: Record; +} + +const DEFAULT_OPTIONS: Required = { + maxRetries: 5, + maxDelay: 60000, + onRetry: () => {}, +}; + +function isErrorWithStatus(error: unknown): error is ErrorWithStatus { + return typeof error === 'object' && error !== null; +} + +function isRateLimitError(error: unknown): boolean { + if (!isErrorWithStatus(error)) { + return false; + } + + const message = typeof error.message === 'string' ? error.message.toLowerCase() : ''; + + return ( + error.status === 429 || + error.statusCode === 429 || + error.response?.status === 429 || + message.includes('429') || + message.includes('too many requests') + ); +} + +function extractRateLimitInfo(error: unknown): RateLimitInfo | undefined { + if (!isErrorWithStatus(error) || !error.headers) { + return undefined; + } + + const headers = error.headers; + + if (!headers['x-ratelimit-reset']) { + return undefined; + } + + const resetValueMs = Number.parseInt(headers['x-ratelimit-reset'], 10); + if (Number.isNaN(resetValueMs)) { + return undefined; + } + + const limit = headers['x-ratelimit-limit'] ? Number.parseInt(headers['x-ratelimit-limit'], 10) : undefined; + const remaining = headers['x-ratelimit-remaining'] + ? Number.parseInt(headers['x-ratelimit-remaining'], 10) + : undefined; + + return { + retryAfter: resetValueMs, + limit, + remaining, + }; +} + +export async function retryWithBackoff(fn: () => Promise, options: RetryOptions = {}): Promise { + const opts = { ...DEFAULT_OPTIONS, ...options }; + + for (let attempt = 0; attempt <= opts.maxRetries; attempt++) { + try { + return await fn(); + } catch (error: unknown) { + if (attempt === opts.maxRetries) { + throw error; + } + + if (!isRateLimitError(error)) { + throw error; + } + + const rateLimitInfo = extractRateLimitInfo(error); + const delayMs = rateLimitInfo ? rateLimitInfo.retryAfter : opts.maxDelay; + + opts.onRetry(attempt + 1, delayMs, rateLimitInfo); + + await wait(delayMs); + } + } + + throw new Error('Maximum retries exceeded'); +} diff --git a/src/app/share/services/share.service.ts b/src/app/share/services/share.service.ts index 6b76c6fb7..e8b3be057 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; @@ -123,12 +124,21 @@ export function getPublicSharedFolderContent( code?: string, orderBy?: 'views:ASC' | 'views:DESC' | 'createdAt:ASC' | 'createdAt:DESC', ): 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, code, orderBy); + }, + { + maxRetries: 5, + maxDelay: 60000, + onRetry: (attempt, delay) => { + console.log( + `[PUBLIC-SHARED-${type.toUpperCase()}] Retry attempt ${attempt} after ${delay}ms for folder ${sharedFolderId}`, + ); + }, + }, + ); } export function deleteShareLink(shareId: string): Promise<{ deleted: boolean; shareId: string }> { 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" From 22e0f9c71e9477d8eecd97160705741b833c6c4d Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 3 Feb 2026 23:45:43 -0400 Subject: [PATCH 2/5] refactor: Improve rate limit retry mechanism with user feedback - Remove maxDelay option and related fallback logic - Simplify rate limit detection to only check status codes (429) - Update rate limit header from 'x-ratelimit-reset' to 'x-internxt-ratelimit-reset' - Remove limit and remaining fields from RateLimitInfo interface - Add user-facing toast notification on first retry attempt - Convert functions to arrow functions for consistency - Add HTTP_CODES.TOO_MANY_REQUESTS constant - Add JSDoc documentation to retryWithBackoff function - Add internationalization support for retry notification - Update tests to reflect new behavior and validate toast integration - Fix loop condition to prevent unreachable code --- src/app/core/constants.ts | 1 + 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/app/network/retry-with-rate-limit.test.ts | 52 +++++----- src/app/network/retry-with-rate-limit.ts | 98 +++++++++---------- src/app/share/services/share.service.ts | 3 +- 12 files changed, 94 insertions(+), 84 deletions(-) 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..60b8ac906 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -1150,7 +1150,8 @@ "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", + "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 index 8a9d9d85a..23b20d782 100644 --- a/src/app/network/retry-with-rate-limit.test.ts +++ b/src/app/network/retry-with-rate-limit.test.ts @@ -6,6 +6,19 @@ vi.mock('utils/timeUtils', () => ({ wait: vi.fn(() => Promise.resolve()), })); +vi.mock('app/notifications/services/notifications.service', () => ({ + default: { + show: vi.fn(), + }, + ToastType: { + Warning: 'warning', + }, +})); + +vi.mock('i18next', () => ({ + t: vi.fn((key: string) => key), +})); + const createRateLimitError = (status: number, headers?: Record, message?: string) => ({ status, headers, @@ -15,7 +28,9 @@ const createRateLimitError = (status: number, headers?: Record, const createRateLimitMock = (resetDelay: string, additionalHeaders?: Record) => vi .fn() - .mockRejectedValueOnce(createRateLimitError(429, { 'x-ratelimit-reset': resetDelay, ...additionalHeaders })) + .mockRejectedValueOnce( + createRateLimitError(429, { 'x-internxt-ratelimit-reset': resetDelay, ...additionalHeaders }), + ) .mockResolvedValueOnce('success'); describe('retryWithBackoff', () => { @@ -45,9 +60,8 @@ describe('retryWithBackoff', () => { it('when different error formats received then recognizes all as rate limits', async () => { const testCases = [ - { statusCode: 429, headers: { 'x-ratelimit-reset': '1000' } }, - { response: { status: 429 }, headers: { 'x-ratelimit-reset': '1000' } }, - { message: 'Too Many Requests', headers: { 'x-ratelimit-reset': '1000' } }, + { status: 429, headers: { 'x-internxt-ratelimit-reset': '1000' } }, + { response: { status: 429 }, headers: { 'x-internxt-ratelimit-reset': '1000' } }, ]; for (const errorFormat of testCases) { @@ -68,10 +82,7 @@ describe('retryWithBackoff', () => { }); it('when headers provided then extracts info and calls callback', async () => { - const mockFn = createRateLimitMock('10000', { - 'x-ratelimit-limit': '100', - 'x-ratelimit-remaining': '0', - }); + const mockFn = createRateLimitMock('10000'); const onRetry = vi.fn(); @@ -80,34 +91,27 @@ describe('retryWithBackoff', () => { expect(timeUtils.wait).toHaveBeenCalledWith(10000); expect(onRetry).toHaveBeenCalledWith(1, 10000, { retryAfter: 10000, - limit: 100, - remaining: 0, }); }); - it('when headers missing or invalid then uses maxDelay fallback', async () => { - const onRetry = vi.fn(); - + it('when headers missing or invalid then throws error', async () => { const testCases = [ - { error: { status: 429 }, delay: 30000, onRetry }, - { error: { status: 429, headers: { 'x-ratelimit-reset': 'invalid' } }, delay: 20000 }, + { status: 429 }, + { status: 429, headers: {} }, + { status: 429, headers: { 'x-internxt-ratelimit-reset': 'invalid' } }, ]; - for (const { error, delay, onRetry: callback } of testCases) { - const mockFn = vi.fn().mockRejectedValueOnce(error).mockResolvedValueOnce('success'); - const options = callback ? { maxDelay: delay, onRetry: callback } : { maxDelay: delay }; - await retryWithBackoff(mockFn, options); - expect(timeUtils.wait).toHaveBeenCalledWith(delay); - if (callback) { - expect(callback).toHaveBeenCalledWith(1, delay, undefined); - } + 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-ratelimit-reset': '1000' } }); + 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'); diff --git a/src/app/network/retry-with-rate-limit.ts b/src/app/network/retry-with-rate-limit.ts index 2d40111aa..af4cb6d13 100644 --- a/src/app/network/retry-with-rate-limit.ts +++ b/src/app/network/retry-with-rate-limit.ts @@ -1,14 +1,15 @@ import { wait } from 'utils/timeUtils'; +import { HTTP_CODES } from 'app/core/constants'; +import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; +import { t } from 'i18next'; +let hasShownRateLimitToast = false; export interface RateLimitInfo { retryAfter: number; - limit?: number; - remaining?: number; } export interface RetryOptions { maxRetries?: number; - maxDelay?: number; onRetry?: (attempt: number, delay: number, rateLimitInfo?: RateLimitInfo) => void; } @@ -18,87 +19,84 @@ interface ErrorWithStatus { message?: string; response?: { status?: number; + headers?: Record; }; headers?: Record; } -const DEFAULT_OPTIONS: Required = { - maxRetries: 5, - maxDelay: 60000, - onRetry: () => {}, -}; - -function isErrorWithStatus(error: unknown): error is ErrorWithStatus { +const isErrorWithStatus = (error: unknown): error is ErrorWithStatus => { return typeof error === 'object' && error !== null; -} +}; -function isRateLimitError(error: unknown): boolean { +const isRateLimitError = (error: unknown): boolean => { if (!isErrorWithStatus(error)) { return false; } + return error.status === HTTP_CODES.TOO_MANY_REQUESTS || error.response?.status === HTTP_CODES.TOO_MANY_REQUESTS; +}; - const message = typeof error.message === 'string' ? error.message.toLowerCase() : ''; - - return ( - error.status === 429 || - error.statusCode === 429 || - error.response?.status === 429 || - message.includes('429') || - message.includes('too many requests') - ); -} - -function extractRateLimitInfo(error: unknown): RateLimitInfo | undefined { - if (!isErrorWithStatus(error) || !error.headers) { - return undefined; - } - +const extractRateLimitInfo = (error: ErrorWithStatus): RateLimitInfo | undefined => { const headers = error.headers; - - if (!headers['x-ratelimit-reset']) { + const resetHeader = headers?.['x-internxt-ratelimit-reset']; + if (!resetHeader) { return undefined; } - const resetValueMs = Number.parseInt(headers['x-ratelimit-reset'], 10); + const resetValueMs = Number.parseInt(resetHeader, 10); if (Number.isNaN(resetValueMs)) { return undefined; } - const limit = headers['x-ratelimit-limit'] ? Number.parseInt(headers['x-ratelimit-limit'], 10) : undefined; - const remaining = headers['x-ratelimit-remaining'] - ? Number.parseInt(headers['x-ratelimit-remaining'], 10) - : undefined; - return { retryAfter: resetValueMs, - limit, - remaining, }; -} +}; -export async function retryWithBackoff(fn: () => Promise, options: RetryOptions = {}): Promise { - const opts = { ...DEFAULT_OPTIONS, ...options }; +/** + * Retries a function when it encounters a rate limit error (429). + * Uses the retry-after value from the x-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 - Callback invoked before each retry with attempt number, delay, and rate limit info + * @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++) { + for (let attempt = 0; attempt < opts.maxRetries; attempt++) { try { return await fn(); } catch (error: unknown) { - if (attempt === opts.maxRetries) { + if (!isRateLimitError(error)) { throw error; } - if (!isRateLimitError(error)) { + const rateLimitInfo = extractRateLimitInfo(error as ErrorWithStatus); + + if (!rateLimitInfo) { throw error; } - const rateLimitInfo = extractRateLimitInfo(error); - const delayMs = rateLimitInfo ? rateLimitInfo.retryAfter : opts.maxDelay; + if (!hasShownRateLimitToast) { + hasShownRateLimitToast = true; + notificationsService.show({ + text: t('shared-links.toast.rate-limit-retry'), + type: ToastType.Warning, + }); + } - opts.onRetry(attempt + 1, delayMs, rateLimitInfo); + opts.onRetry(attempt + 1, rateLimitInfo.retryAfter, rateLimitInfo); - await wait(delayMs); + await wait(rateLimitInfo.retryAfter); } } - throw new Error('Maximum retries exceeded'); -} + return await fn(); +}; diff --git a/src/app/share/services/share.service.ts b/src/app/share/services/share.service.ts index e8b3be057..b68cc5526 100644 --- a/src/app/share/services/share.service.ts +++ b/src/app/share/services/share.service.ts @@ -131,9 +131,8 @@ export function getPublicSharedFolderContent( }, { maxRetries: 5, - maxDelay: 60000, onRetry: (attempt, delay) => { - console.log( + console.warn( `[PUBLIC-SHARED-${type.toUpperCase()}] Retry attempt ${attempt} after ${delay}ms for folder ${sharedFolderId}`, ); }, From cd0f3ad5994b8353747a1d977c49d207390a37ac Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 4 Feb 2026 11:12:50 -0400 Subject: [PATCH 3/5] refactor: Simplify rate limit retry mechanism and improve toast persistence --- src/app/network/retry-with-rate-limit.test.ts | 18 +--------- src/app/network/retry-with-rate-limit.ts | 33 +++++++------------ src/app/share/services/share.service.ts | 1 - 3 files changed, 13 insertions(+), 39 deletions(-) diff --git a/src/app/network/retry-with-rate-limit.test.ts b/src/app/network/retry-with-rate-limit.test.ts index 23b20d782..181562320 100644 --- a/src/app/network/retry-with-rate-limit.test.ts +++ b/src/app/network/retry-with-rate-limit.test.ts @@ -58,20 +58,6 @@ describe('retryWithBackoff', () => { expect(timeUtils.wait).toHaveBeenCalledWith(5000); }); - it('when different error formats received then recognizes all as rate limits', async () => { - const testCases = [ - { status: 429, headers: { 'x-internxt-ratelimit-reset': '1000' } }, - { response: { status: 429 }, headers: { 'x-internxt-ratelimit-reset': '1000' } }, - ]; - - for (const errorFormat of testCases) { - const mockFn = vi.fn().mockRejectedValueOnce(errorFormat).mockResolvedValueOnce('success'); - await retryWithBackoff(mockFn); - expect(mockFn).toHaveBeenCalledTimes(2); - vi.clearAllMocks(); - } - }); - it('when error is not rate limit then throws immediately without retry', async () => { const mockFn = vi.fn().mockRejectedValue({ status: 500, message: 'Internal Server Error' }); @@ -89,9 +75,7 @@ describe('retryWithBackoff', () => { await retryWithBackoff(mockFn, { onRetry }); expect(timeUtils.wait).toHaveBeenCalledWith(10000); - expect(onRetry).toHaveBeenCalledWith(1, 10000, { - retryAfter: 10000, - }); + expect(onRetry).toHaveBeenCalledWith(1, 10000); }); it('when headers missing or invalid then throws error', async () => { diff --git a/src/app/network/retry-with-rate-limit.ts b/src/app/network/retry-with-rate-limit.ts index af4cb6d13..c91a6e5e5 100644 --- a/src/app/network/retry-with-rate-limit.ts +++ b/src/app/network/retry-with-rate-limit.ts @@ -4,23 +4,14 @@ import notificationsService, { ToastType } from 'app/notifications/services/noti import { t } from 'i18next'; let hasShownRateLimitToast = false; -export interface RateLimitInfo { - retryAfter: number; -} export interface RetryOptions { maxRetries?: number; - onRetry?: (attempt: number, delay: number, rateLimitInfo?: RateLimitInfo) => void; + onRetry?: (attempt: number, delay: number) => void; } interface ErrorWithStatus { status?: number; - statusCode?: number; - message?: string; - response?: { - status?: number; - headers?: Record; - }; headers?: Record; } @@ -32,10 +23,10 @@ const isRateLimitError = (error: unknown): boolean => { if (!isErrorWithStatus(error)) { return false; } - return error.status === HTTP_CODES.TOO_MANY_REQUESTS || error.response?.status === HTTP_CODES.TOO_MANY_REQUESTS; + return error.status === HTTP_CODES.TOO_MANY_REQUESTS; }; -const extractRateLimitInfo = (error: ErrorWithStatus): RateLimitInfo | undefined => { +const extractRetryAfter = (error: ErrorWithStatus): number | undefined => { const headers = error.headers; const resetHeader = headers?.['x-internxt-ratelimit-reset']; if (!resetHeader) { @@ -47,19 +38,18 @@ const extractRateLimitInfo = (error: ErrorWithStatus): RateLimitInfo | undefined return undefined; } - return { - retryAfter: resetValueMs, - }; + return resetValueMs; }; /** * Retries a function when it encounters a rate limit error (429). - * Uses the retry-after value from the x-ratelimit-reset header to wait before retrying. + * Uses the retry-after value from the x-internxt-ratelimit-reset header to wait before retrying. + * Shows a warning toast notification on the first rate limit encounter. * * @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 - Callback invoked before each retry with attempt number, delay, and rate limit info + * @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 */ @@ -78,9 +68,9 @@ export const retryWithBackoff = async (fn: () => Promise, options: RetryOp throw error; } - const rateLimitInfo = extractRateLimitInfo(error as ErrorWithStatus); + const retryAfter = extractRetryAfter(error as ErrorWithStatus); - if (!rateLimitInfo) { + if (!retryAfter) { throw error; } @@ -89,12 +79,13 @@ export const retryWithBackoff = async (fn: () => Promise, options: RetryOp notificationsService.show({ text: t('shared-links.toast.rate-limit-retry'), type: ToastType.Warning, + duration: Infinity, }); } - opts.onRetry(attempt + 1, rateLimitInfo.retryAfter, rateLimitInfo); + opts.onRetry(attempt + 1, retryAfter); - await wait(rateLimitInfo.retryAfter); + await wait(retryAfter); } } diff --git a/src/app/share/services/share.service.ts b/src/app/share/services/share.service.ts index b68cc5526..17ad98373 100644 --- a/src/app/share/services/share.service.ts +++ b/src/app/share/services/share.service.ts @@ -130,7 +130,6 @@ export function getPublicSharedFolderContent( return shareClient.getPublicSharedFolderContent(sharedFolderId, type, token, page, perPage, code, orderBy); }, { - maxRetries: 5, onRetry: (attempt, delay) => { console.warn( `[PUBLIC-SHARED-${type.toUpperCase()}] Retry attempt ${attempt} after ${delay}ms for folder ${sharedFolderId}`, From 6b7760c1da3d5705831c633f1b6693a9a65f1a0f Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 4 Feb 2026 23:29:55 -0400 Subject: [PATCH 4/5] refactor: Show rate limit notification only once per session --- src/app/network/retry-with-rate-limit.test.ts | 41 ++++++------------- src/app/network/retry-with-rate-limit.ts | 14 ------- src/app/share/services/share.service.ts | 10 +++++ 3 files changed, 23 insertions(+), 42 deletions(-) diff --git a/src/app/network/retry-with-rate-limit.test.ts b/src/app/network/retry-with-rate-limit.test.ts index 181562320..8fdfda075 100644 --- a/src/app/network/retry-with-rate-limit.test.ts +++ b/src/app/network/retry-with-rate-limit.test.ts @@ -6,32 +6,13 @@ vi.mock('utils/timeUtils', () => ({ wait: vi.fn(() => Promise.resolve()), })); -vi.mock('app/notifications/services/notifications.service', () => ({ - default: { - show: vi.fn(), - }, - ToastType: { - Warning: 'warning', - }, -})); - -vi.mock('i18next', () => ({ - t: vi.fn((key: string) => key), -})); - -const createRateLimitError = (status: number, headers?: Record, message?: string) => ({ - status, - headers, - ...(message && { message }), +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(429, { 'x-internxt-ratelimit-reset': resetDelay, ...additionalHeaders }), - ) - .mockResolvedValueOnce('success'); + vi.fn().mockRejectedValueOnce(createRateLimitError(resetDelay, additionalHeaders)).mockResolvedValueOnce('success'); describe('retryWithBackoff', () => { beforeEach(() => { @@ -67,15 +48,19 @@ describe('retryWithBackoff', () => { expect(timeUtils.wait).not.toHaveBeenCalled(); }); - it('when headers provided then extracts info and calls callback', async () => { - const mockFn = createRateLimitMock('10000'); + 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(); - await retryWithBackoff(mockFn, { onRetry }); + const result = await retryWithBackoff(mockFn, { onRetry }); - expect(timeUtils.wait).toHaveBeenCalledWith(10000); - expect(onRetry).toHaveBeenCalledWith(1, 10000); + 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 () => { diff --git a/src/app/network/retry-with-rate-limit.ts b/src/app/network/retry-with-rate-limit.ts index c91a6e5e5..10cb95f55 100644 --- a/src/app/network/retry-with-rate-limit.ts +++ b/src/app/network/retry-with-rate-limit.ts @@ -1,9 +1,5 @@ import { wait } from 'utils/timeUtils'; import { HTTP_CODES } from 'app/core/constants'; -import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; -import { t } from 'i18next'; - -let hasShownRateLimitToast = false; export interface RetryOptions { maxRetries?: number; @@ -44,7 +40,6 @@ const extractRetryAfter = (error: ErrorWithStatus): number | undefined => { /** * 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. - * Shows a warning toast notification on the first rate limit encounter. * * @param fn - The async function to execute with retry logic * @param options - Configuration options for retry behavior @@ -74,15 +69,6 @@ export const retryWithBackoff = async (fn: () => Promise, options: RetryOp throw error; } - if (!hasShownRateLimitToast) { - hasShownRateLimitToast = true; - notificationsService.show({ - text: t('shared-links.toast.rate-limit-retry'), - type: ToastType.Warning, - duration: Infinity, - }); - } - opts.onRetry(attempt + 1, retryAfter); await wait(retryAfter); diff --git a/src/app/share/services/share.service.ts b/src/app/share/services/share.service.ts index 17ad98373..695f200fd 100644 --- a/src/app/share/services/share.service.ts +++ b/src/app/share/services/share.service.ts @@ -115,6 +115,8 @@ export function getSharedFolderContent( }); } +let hasShownRateLimitNotification = false; + export function getPublicSharedFolderContent( sharedFolderId: string, type: 'folders' | 'files', @@ -134,6 +136,14 @@ export function getPublicSharedFolderContent( console.warn( `[PUBLIC-SHARED-${type.toUpperCase()}] Retry attempt ${attempt} after ${delay}ms for folder ${sharedFolderId}`, ); + if (!hasShownRateLimitNotification) { + hasShownRateLimitNotification = true; + notificationsService.show({ + text: t('shared-links.toast.rate-limit-retry'), + type: ToastType.Warning, + duration: Infinity, + }); + } }, }, ); From 34d5f3baf4d16f19eaeec9745dd5c42dc730670b Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 5 Feb 2026 10:30:12 -0400 Subject: [PATCH 5/5] refactor: Move rate limit notification logic to UI layer --- src/app/i18n/locales/de.json | 1 + src/app/share/services/share.service.ts | 74 ++++++++++++++-------- src/views/PublicShared/ShareFolderView.tsx | 13 ++++ 3 files changed, 60 insertions(+), 28 deletions(-) diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index 60b8ac906..235c56374 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -1151,6 +1151,7 @@ "link-updated": "Linkeinstellungen aktualisiert", "link-deleted": "Link 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..." } }, diff --git a/src/app/share/services/share.service.ts b/src/app/share/services/share.service.ts index 695f200fd..130e0ad54 100644 --- a/src/app/share/services/share.service.ts +++ b/src/app/share/services/share.service.ts @@ -115,36 +115,33 @@ export function getSharedFolderContent( }); } -let hasShownRateLimitNotification = false; - export function getPublicSharedFolderContent( sharedFolderId: string, type: 'folders' | 'files', 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 { return retryWithBackoff( async () => { const shareClient = SdkFactory.getNewApiInstance().createShareClient(); - return shareClient.getPublicSharedFolderContent(sharedFolderId, type, token, page, perPage, code, orderBy); + return shareClient.getPublicSharedFolderContent( + sharedFolderId, + type, + token, + page, + perPage, + options?.code, + options?.orderBy, + ); }, { - onRetry: (attempt, delay) => { - console.warn( - `[PUBLIC-SHARED-${type.toUpperCase()}] Retry attempt ${attempt} after ${delay}ms for folder ${sharedFolderId}`, - ); - if (!hasShownRateLimitNotification) { - hasShownRateLimitNotification = true; - notificationsService.show({ - text: t('shared-links.toast.rate-limit-retry'), - type: ToastType.Warning, - duration: Infinity, - }); - } - }, + onRetry: options?.onRetry, }, ); } @@ -504,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; @@ -533,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, ) { @@ -546,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, @@ -554,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; @@ -675,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; @@ -694,7 +712,7 @@ export async function downloadPublicSharedFolder({ '', 0, 15, - code, + { code, onRetry }, ); if (!credentials) { @@ -703,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, ); @@ -711,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);