From 0c780671b7b0368a4bc40cff17548041305275fa Mon Sep 17 00:00:00 2001 From: Ramon Candel Date: Wed, 4 Feb 2026 10:57:17 +0100 Subject: [PATCH 1/4] Add rate limit handling --- package.json | 2 +- src/network/upload.ts | 24 +-- src/plugins/RateLimitPlugin.ts | 44 +++++ src/plugins/index.ts | 3 +- src/services/common/index.ts | 1 + src/services/common/rate-limit/index.ts | 3 + .../rate-limit/rate-limit.interceptors.ts | 107 ++++++++++++ .../common/rate-limit/rate-limit.retry.ts | 33 ++++ .../common/rate-limit/rate-limit.service.ts | 163 ++++++++++++++++++ yarn.lock | 8 +- 10 files changed, 371 insertions(+), 17 deletions(-) create mode 100644 src/plugins/RateLimitPlugin.ts create mode 100644 src/services/common/rate-limit/index.ts create mode 100644 src/services/common/rate-limit/rate-limit.interceptors.ts create mode 100644 src/services/common/rate-limit/rate-limit.retry.ts create mode 100644 src/services/common/rate-limit/rate-limit.service.ts diff --git a/package.json b/package.json index ea10f0e3f..db4657c8f 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "@internxt/lib": "^1.4.1", "@internxt/mobile-sdk": "https://github.com/internxt/mobile-sdk/releases/download/v0.3.1/internxt-mobile-sdk-v0.3.1_.tgz", "@internxt/rn-crypto": "0.1.15", - "@internxt/sdk": "1.11.25", + "@internxt/sdk": "1.12.3", "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/bottom-tabs": "^6.2.0", "@react-navigation/native": "^6.1.18", diff --git a/src/network/upload.ts b/src/network/upload.ts index dcf069b5f..8e6cbfdd4 100644 --- a/src/network/upload.ts +++ b/src/network/upload.ts @@ -1,5 +1,6 @@ import * as RNFS from '@dr.pogodin/react-native-fs'; import { logger } from '../services/common'; +import { withRateLimitRetry } from '../services/common/rate-limit'; import { Abortable } from '../types'; import { getNetwork } from './NetworkFacade'; import { NetworkCredentials } from './requests'; @@ -32,18 +33,19 @@ export async function uploadFile( async function retryUpload(): Promise { const MAX_TRIES = 3; const RETRY_DELAY = 1000; - let uploadPromise: Promise; let lastTryError; for (let attempt = 1; attempt <= MAX_TRIES; attempt++) { try { - if (useMultipart) { - uploadPromise = network.uploadMultipart(bucketId, mnemonic, filePath, { - partSize: MULTIPART_PART_SIZE, - uploadingCallback: params.notifyProgress, - abortController: uploadAbortController.signal, - }); - } else { + const result = await withRateLimitRetry(async () => { + if (useMultipart) { + return await network.uploadMultipart(bucketId, mnemonic, filePath, { + partSize: MULTIPART_PART_SIZE, + uploadingCallback: params.notifyProgress, + abortController: uploadAbortController.signal, + }); + } + const [promise, abortable] = await network.upload(bucketId, mnemonic, filePath, { progress: params.notifyProgress, }); @@ -52,10 +54,10 @@ export async function uploadFile( onAbortableReady(abortable); } - uploadPromise = promise; - } + return await promise; + }, 'Upload'); - return await uploadPromise; + return result; } catch (err) { logger.error(`Upload attempt ${attempt} of ${MAX_TRIES} failed:`, err); diff --git a/src/plugins/RateLimitPlugin.ts b/src/plugins/RateLimitPlugin.ts new file mode 100644 index 000000000..0b6496876 --- /dev/null +++ b/src/plugins/RateLimitPlugin.ts @@ -0,0 +1,44 @@ +import { HttpClient } from '@internxt/sdk/dist/shared/http/client'; +import axios from 'axios'; +import { extractEndpointKey, rateLimitInterceptors, rateLimitService } from '../services/common/rate-limit'; +import { AppPlugin } from '../types'; + +/** + * Registers rate limit interceptors at two levels: + * - SDK HttpClient: intercepts all SDK calls before extractData/normalizeError + * - Global axios: intercepts direct axios calls outside the SDK (PaymentService, downloads, etc.) + * + * Must be installed BEFORE AxiosPlugin so rate limit interceptors run first in the chain. + */ +const rateLimitPlugin: AppPlugin = { + install(_store): void { + HttpClient.setGlobalInterceptors(rateLimitInterceptors); + + axios.interceptors.request.use( + async (config) => { + const endpointKey = extractEndpointKey(config); + await rateLimitService.waitIfNeeded(endpointKey); + return config; + }, + (error) => Promise.reject(error), + ); + axios.interceptors.response.use( + (response) => { + if (response.headers) { + const endpointKey = extractEndpointKey(response.config); + rateLimitService.updateFromHeaders(response.headers as Record, endpointKey); + } + return response; + }, + (error) => { + if (error.response?.headers) { + const endpointKey = extractEndpointKey(error.config); + rateLimitService.updateFromHeaders(error.response.headers as Record, endpointKey); + } + return Promise.reject(error); + }, + ); + }, +}; + +export default rateLimitPlugin; diff --git a/src/plugins/index.ts b/src/plugins/index.ts index 3e198ae19..c7b694537 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -1,6 +1,7 @@ // import sentryPlugin from './SentryPlugin'; import axiosPlugin from './AxiosPlugin'; +import rateLimitPlugin from './RateLimitPlugin'; -const plugins = [axiosPlugin]; +const plugins = [rateLimitPlugin, axiosPlugin]; export default plugins; diff --git a/src/services/common/index.ts b/src/services/common/index.ts index 648aa86d2..4bf78f369 100644 --- a/src/services/common/index.ts +++ b/src/services/common/index.ts @@ -4,3 +4,4 @@ export * from './errors'; export * from './media'; export * from './filesystem'; export * from './biometrics'; +export * from './rate-limit'; diff --git a/src/services/common/rate-limit/index.ts b/src/services/common/rate-limit/index.ts new file mode 100644 index 000000000..8c2394bba --- /dev/null +++ b/src/services/common/rate-limit/index.ts @@ -0,0 +1,3 @@ +export { rateLimitService, extractEndpointKey, MAX_RATE_LIMIT_RETRIES, HTTP_TOO_MANY_REQUESTS } from './rate-limit.service'; +export { rateLimitInterceptors } from './rate-limit.interceptors'; +export { withRateLimitRetry } from './rate-limit.retry'; diff --git a/src/services/common/rate-limit/rate-limit.interceptors.ts b/src/services/common/rate-limit/rate-limit.interceptors.ts new file mode 100644 index 000000000..174935d9d --- /dev/null +++ b/src/services/common/rate-limit/rate-limit.interceptors.ts @@ -0,0 +1,107 @@ +import { logger } from '@internxt-mobile/services/common'; +import axios, { AxiosResponse, InternalAxiosRequestConfig } from 'axios'; +import { + HTTP_TOO_MANY_REQUESTS, + MAX_RATE_LIMIT_RETRIES, + extractEndpointKey, + rateLimitService, +} from './rate-limit.service'; + +interface AxiosErrorLike { + response?: { status?: number; headers?: Record }; + config?: InternalAxiosRequestConfig & { __rateLimitRetry?: number }; +} + +/** + * Interceptors to pass to SDK's HttpClient. + * They run BEFORE extractData/normalizeError so they see full response headers. + * + * - Request: proactive throttle when remaining quota is low + * - Response success: track rate limit state from headers + * - Response error: on 429, wait and retry transparently using raw axios + * (raw axios avoids the double-processing issue with extractData) + */ +export const rateLimitInterceptors = [ + { + request: { + onFulfilled: async (config: InternalAxiosRequestConfig): Promise => { + const endpointKey = extractEndpointKey(config); + await rateLimitService.waitIfNeeded(endpointKey); + return config; + }, + }, + response: { + onFulfilled: (response: AxiosResponse): AxiosResponse => { + if (response.headers) { + const endpointKey = extractEndpointKey(response.config); + logResponseHeaders(response); + rateLimitService.updateFromHeaders(response.headers as Record, endpointKey); + } + return response; + }, + onRejected: async (error: unknown): Promise => { + const axiosError = error as AxiosErrorLike; + const endpointKey = axiosError.config ? extractEndpointKey(axiosError.config) : 'unknown'; + + if (axiosError.response?.headers) { + logErrorHeaders(axiosError); + rateLimitService.updateFromHeaders(axiosError.response.headers, endpointKey); + } + + if (axiosError.response?.status === HTTP_TOO_MANY_REQUESTS && axiosError.config) { + const attempt = (axiosError.config.__rateLimitRetry ?? 0) + 1; + + if (attempt <= MAX_RATE_LIMIT_RETRIES) { + const retryAfter = axiosError.response.headers?.['retry-after']; + const delay = rateLimitService.getRetryDelay(retryAfter, endpointKey); + logRetry(attempt, delay); + + await new Promise((resolve) => setTimeout(resolve, delay)); + + // Retry with raw axios (not the intercepted instance) to avoid + // extractData processing the result twice on the original chain. + const config = { ...axiosError.config, __rateLimitRetry: attempt }; + return axios(config); + } + + logRetriesExhausted(); + } + + throw error; + }, + }, + }, +]; + +const logResponseHeaders = (response: AxiosResponse) => { + if (!__DEV__) return; + const h = response.headers as Record; + const method = response.config?.method?.toUpperCase(); + const endpoint = response.config?.url || 'unknown'; + const limit = h['x-ratelimit-limit']; + const remaining = h['x-ratelimit-remaining']; + const reset = h['x-ratelimit-reset']; + if (limit || remaining || reset) { + logger.info(`[RateLimit] ${method} ${endpoint} → limit=${limit} remaining=${remaining} reset=${reset}`); + } +}; + +const logErrorHeaders = (axiosError: AxiosErrorLike) => { + if (!__DEV__ || !axiosError.response?.headers) return; + const h = axiosError.response.headers; + const method = axiosError.config?.method?.toUpperCase(); + const endpoint = axiosError.config?.url || 'unknown'; + const status = axiosError.response.status; + logger.warn( + `[RateLimit] ${method} ${endpoint} → ${status} | ` + + `limit=${h['x-ratelimit-limit']} remaining=${h['x-ratelimit-remaining']} reset=${h['x-ratelimit-reset']}`, + ); +}; + +const logRetry = (attempt: number, delay: number) => { + logger.warn(`[RateLimit] 429 received, retry ${attempt}/${MAX_RATE_LIMIT_RETRIES} after ${delay}ms`); +}; + +const logRetriesExhausted = () => { + logger.error(`[RateLimit] 429 max retries (${MAX_RATE_LIMIT_RETRIES}) exhausted`); +}; diff --git a/src/services/common/rate-limit/rate-limit.retry.ts b/src/services/common/rate-limit/rate-limit.retry.ts new file mode 100644 index 000000000..325cef1b8 --- /dev/null +++ b/src/services/common/rate-limit/rate-limit.retry.ts @@ -0,0 +1,33 @@ +import { logger } from '@internxt-mobile/services/common'; +import { HTTP_TOO_MANY_REQUESTS, MAX_RATE_LIMIT_RETRIES, rateLimitService } from './rate-limit.service'; + +/** + * Wraps an async operation with rate-limit-aware retry logic. + * On 429, waits using rateLimitService delay and retries without consuming caller retries. + * Returns the result or throws the original error if retries are exhausted. + */ +export const withRateLimitRetry = async ( + operation: () => Promise, + context: string, + endpointKey?: string, +): Promise => { + let rateLimitRetries = 0; + + // eslint-disable-next-line no-constant-condition + while (true) { + try { + return await operation(); + } catch (err) { + const errorStatus = (err as { status?: number }).status; + const isRateLimited = errorStatus === HTTP_TOO_MANY_REQUESTS; + const canRetry = isRateLimited && rateLimitRetries < MAX_RATE_LIMIT_RETRIES; + + if (!canRetry) throw err; + + rateLimitRetries++; + const delay = rateLimitService.getRetryDelay(undefined, endpointKey); + logger.warn(`[RateLimit] ${context} 429, retry ${rateLimitRetries}/${MAX_RATE_LIMIT_RETRIES} after ${delay}ms`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } +}; diff --git a/src/services/common/rate-limit/rate-limit.service.ts b/src/services/common/rate-limit/rate-limit.service.ts new file mode 100644 index 000000000..de5020033 --- /dev/null +++ b/src/services/common/rate-limit/rate-limit.service.ts @@ -0,0 +1,163 @@ +import { logger } from '@internxt-mobile/services/common'; + +interface RateLimitState { + limit: number; + remaining: number; + resetMs: number; +} + +interface EndpointConfig { + baseURL?: string; + url?: string; +} + +/** + * Matches complete path segments that look like dynamic IDs: + * - UUIDs: /xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + * - Hex IDs (12+ chars): /c6fe170df34863c173430633 (MongoDB ObjectIDs, etc.) + * - Numeric IDs: /12345 + * Only matches full segments (between slashes or at end of path). + */ +const ID_SEGMENT_PATTERN = + /\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{12,}|\d+)(?=\/|$)/gi; +const UNKNOWN_ENDPOINT = 'unknown'; + +export const HTTP_TOO_MANY_REQUESTS = 429; +const THROTTLE_THRESHOLD = 0.4; +const MAX_THROTTLE_DELAY_MS = 2000; +export const MAX_RATE_LIMIT_RETRIES = 3; +const RETRY_BUFFER_MS = 2000; +const BASE_BACKOFF_MS = 3000; +const MAX_BACKOFF_MS = 5000; + +/** + * Extracts a normalized endpoint key from an axios config. + * Combines baseURL + url, strips query params, and replaces + * UUIDs and numeric IDs with `:id` for consistent grouping. + * + * Examples: + * { baseURL: "https://gw.internxt.com/drive", url: "/folders/abc-uuid/content" } + * → "https://gw.internxt.com/drive/folders/:id/content" + * { url: "https://gw.internxt.com/payments/display-billing" } + * → "https://gw.internxt.com/payments/display-billing" + */ +export const extractEndpointKey = (config: EndpointConfig): string => { + const base = config.baseURL || ''; + const path = config.url || ''; + if (!base && !path) return UNKNOWN_ENDPOINT; + + const needsSeparator = base && path && !base.endsWith('/') && !path.startsWith('/'); + const hasDoubleSlash = base.endsWith('/') && path.startsWith('/'); + const fullUrl = hasDoubleSlash ? base + path.slice(1) : needsSeparator ? base + '/' + path : base + path; + + try { + const urlObj = new URL(fullUrl); + const normalizedPath = urlObj.pathname.replace(ID_SEGMENT_PATTERN, '/:id'); + return `${urlObj.origin}${normalizedPath}`; + } catch { + const pathWithoutQuery = fullUrl.split('?')[0]; + return pathWithoutQuery.replace(ID_SEGMENT_PATTERN, '/:id'); + } +}; + +class RateLimitService { + private states = new Map(); + + updateFromHeaders(headers: Record, endpointKey: string): void { + const limit = this.parseHeader(headers, 'x-ratelimit-limit'); + const remaining = this.parseHeader(headers, 'x-ratelimit-remaining'); + const reset = this.parseHeader(headers, 'x-ratelimit-reset'); + + if (limit === null || remaining === null || reset === null) return; + + this.states.set(endpointKey, { + limit, + remaining, + resetMs: this.parseResetValue(reset), + }); + } + + shouldThrottle(endpointKey: string): boolean { + const state = this.states.get(endpointKey); + if (!state) return false; + const isQuotaBelowThreshold = state.remaining < state.limit * THROTTLE_THRESHOLD; + return isQuotaBelowThreshold; + } + + async waitIfNeeded(endpointKey: string): Promise { + const state = this.states.get(endpointKey); + if (!state || !this.shouldThrottle(endpointKey)) return; + + const { remaining, resetMs } = state; + const timeUntilReset = Math.max(0, resetMs - Date.now()); + + const isQuotaExhausted = remaining <= 0 && timeUntilReset > 0; + if (isQuotaExhausted) { + const delay = Math.min(timeUntilReset + RETRY_BUFFER_MS, MAX_BACKOFF_MS); + logger.info(`[RateLimit] ${endpointKey} limit reached, waiting ${delay}ms until reset`); + await this.sleep(delay); + return; + } + + const isQuotaLow = remaining > 0 && timeUntilReset > 0; + if (isQuotaLow) { + const delay = Math.min(timeUntilReset / remaining, MAX_THROTTLE_DELAY_MS); + logger.info(`[RateLimit] ${endpointKey} throttling: ${remaining} left, waiting ${Math.round(delay)}ms`); + await this.sleep(delay); + } + } + + getRetryDelay(retryAfterHeader?: string, endpointKey?: string): number { + if (retryAfterHeader) { + const seconds = parseInt(retryAfterHeader, 10); + const isValidRetryAfter = !isNaN(seconds) && seconds > 0; + if (isValidRetryAfter) return seconds * 1000; + } + + const state = endpointKey ? this.states.get(endpointKey) : undefined; + if (state) { + const timeUntilReset = state.resetMs - Date.now(); + const isResetPending = timeUntilReset > 0; + if (isResetPending) return Math.min(timeUntilReset + RETRY_BUFFER_MS, MAX_BACKOFF_MS); + } + + return BASE_BACKOFF_MS; + } + + /** + * Parse reset value heuristically: + * - > 1e12: epoch milliseconds + * - > 1e9: epoch seconds + * - > 1e6: microseconds remaining (backend returns µs, e.g. 33293277 µs ≈ 33s) + * - > 1000: milliseconds remaining + * - otherwise: seconds remaining + */ + private parseResetValue(value: number): number { + const isEpochMs = value > 1e12; + if (isEpochMs) return value; + + const isEpochSeconds = value > 1e9; + if (isEpochSeconds) return value * 1000; + + const isMicroseconds = value > 1e6; + if (isMicroseconds) return Date.now() + value / 1000; + + const isMilliseconds = value > 1000; + if (isMilliseconds) return Date.now() + value; + + return Date.now() + value * 1000; + } + + private parseHeader(headers: Record, key: string): number | null { + const val = headers[key] ?? headers[key.toLowerCase()]; + if (val === undefined || val === null) return null; + const num = parseInt(String(val), 10); + return isNaN(num) ? null : num; + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} + +export const rateLimitService = new RateLimitService(); diff --git a/yarn.lock b/yarn.lock index 7e1d848b8..b4621d5a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1532,10 +1532,10 @@ dependencies: buffer "^6.0.3" -"@internxt/sdk@1.11.25": - version "1.11.25" - resolved "https://npm.pkg.github.com/download/@internxt/sdk/1.11.25/b606f1db93716e6a9d2356fdab2fbf6024431e1f#b606f1db93716e6a9d2356fdab2fbf6024431e1f" - integrity sha512-mVJIDabOjN777ZaUTgaVWZLje3/0mimhJDCThunQ6smKMrei/2UPxBACa9w3k6AKtLE6eUwgKu8/pTz35T5r9Q== +"@internxt/sdk@1.12.3": + version "1.12.3" + resolved "https://npm.pkg.github.com/download/@internxt/sdk/1.12.3/a8e8b92fb74366ede86d6239307771c7c3c3100e#a8e8b92fb74366ede86d6239307771c7c3c3100e" + integrity sha512-rrt2tEUFGAvjD9yJ+kGSVvrSnZiUushqwWh7Bee+cDIFd3c1NCAtCmfhTBZjX/nvMfDqHWA1xeEYgP53qe8tpA== dependencies: axios "1.13.2" uuid "11.1.0" From b8cb2aead293ce4d16e246a55c4c65e78a18cf65 Mon Sep 17 00:00:00 2001 From: Ramon Candel Date: Wed, 4 Feb 2026 12:12:28 +0100 Subject: [PATCH 2/4] Refactor rate limit service to improve ID segment handling and normalize path segments --- .../common/rate-limit/rate-limit.service.ts | 52 +++++++++++++------ 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/src/services/common/rate-limit/rate-limit.service.ts b/src/services/common/rate-limit/rate-limit.service.ts index de5020033..5b45f2ca1 100644 --- a/src/services/common/rate-limit/rate-limit.service.ts +++ b/src/services/common/rate-limit/rate-limit.service.ts @@ -12,14 +12,27 @@ interface EndpointConfig { } /** - * Matches complete path segments that look like dynamic IDs: - * - UUIDs: /xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - * - Hex IDs (12+ chars): /c6fe170df34863c173430633 (MongoDB ObjectIDs, etc.) - * - Numeric IDs: /12345 - * Only matches full segments (between slashes or at end of path). + * Individual patterns for dynamic ID path segments: + * - UUIDs: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + * - Hex IDs (12+ chars): c6fe170df34863c173430633 (MongoDB ObjectIDs, etc.) + * - Numeric IDs: 12345 */ -const ID_SEGMENT_PATTERN = - /\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{12,}|\d+)(?=\/|$)/gi; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const HEX_ID_PATTERN = /^[0-9a-f]{12,}$/i; +const NUMERIC_ID_PATTERN = /^\d+$/; + +const isIdSegment = (segment: string): boolean => { + if (!segment) return false; + return UUID_PATTERN.test(segment) || HEX_ID_PATTERN.test(segment) || NUMERIC_ID_PATTERN.test(segment); +}; + +const normalizePathSegments = (pathname: string): string => { + return pathname + .split('/') + .map((segment) => (isIdSegment(segment) ? ':id' : segment)) + .join('/'); +}; + const UNKNOWN_ENDPOINT = 'unknown'; export const HTTP_TOO_MANY_REQUESTS = 429; @@ -46,22 +59,27 @@ export const extractEndpointKey = (config: EndpointConfig): string => { const path = config.url || ''; if (!base && !path) return UNKNOWN_ENDPOINT; - const needsSeparator = base && path && !base.endsWith('/') && !path.startsWith('/'); - const hasDoubleSlash = base.endsWith('/') && path.startsWith('/'); - const fullUrl = hasDoubleSlash ? base + path.slice(1) : needsSeparator ? base + '/' + path : base + path; + let fullUrl: string; + if (base.endsWith('/') && path.startsWith('/')) { + fullUrl = base + path.slice(1); + } else if (base && path && !base.endsWith('/') && !path.startsWith('/')) { + fullUrl = base + '/' + path; + } else { + fullUrl = base + path; + } try { const urlObj = new URL(fullUrl); - const normalizedPath = urlObj.pathname.replace(ID_SEGMENT_PATTERN, '/:id'); + const normalizedPath = normalizePathSegments(urlObj.pathname); return `${urlObj.origin}${normalizedPath}`; } catch { const pathWithoutQuery = fullUrl.split('?')[0]; - return pathWithoutQuery.replace(ID_SEGMENT_PATTERN, '/:id'); + return normalizePathSegments(pathWithoutQuery); } }; class RateLimitService { - private states = new Map(); + private readonly states = new Map(); updateFromHeaders(headers: Record, endpointKey: string): void { const limit = this.parseHeader(headers, 'x-ratelimit-limit'); @@ -109,8 +127,8 @@ class RateLimitService { getRetryDelay(retryAfterHeader?: string, endpointKey?: string): number { if (retryAfterHeader) { - const seconds = parseInt(retryAfterHeader, 10); - const isValidRetryAfter = !isNaN(seconds) && seconds > 0; + const seconds = Number.parseInt(retryAfterHeader, 10); + const isValidRetryAfter = !Number.isNaN(seconds) && seconds > 0; if (isValidRetryAfter) return seconds * 1000; } @@ -151,8 +169,8 @@ class RateLimitService { private parseHeader(headers: Record, key: string): number | null { const val = headers[key] ?? headers[key.toLowerCase()]; if (val === undefined || val === null) return null; - const num = parseInt(String(val), 10); - return isNaN(num) ? null : num; + const num = Number.parseInt(String(val), 10); + return Number.isNaN(num) ? null : num; } private sleep(ms: number): Promise { From c08d2564972e3500532ceb43d98037054321531d Mon Sep 17 00:00:00 2001 From: Ramon Candel Date: Wed, 4 Feb 2026 12:27:38 +0100 Subject: [PATCH 3/4] Refactor rate limit service and interceptors to use constants for header values --- src/services/common/rate-limit/index.ts | 11 ++++++++++- .../common/rate-limit/rate-limit.interceptors.ts | 14 +++++++++----- .../common/rate-limit/rate-limit.service.ts | 12 +++++++++--- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/services/common/rate-limit/index.ts b/src/services/common/rate-limit/index.ts index 8c2394bba..8133da356 100644 --- a/src/services/common/rate-limit/index.ts +++ b/src/services/common/rate-limit/index.ts @@ -1,3 +1,12 @@ -export { rateLimitService, extractEndpointKey, MAX_RATE_LIMIT_RETRIES, HTTP_TOO_MANY_REQUESTS } from './rate-limit.service'; +export { + rateLimitService, + extractEndpointKey, + MAX_RATE_LIMIT_RETRIES, + HTTP_TOO_MANY_REQUESTS, + HEADER_RATELIMIT_LIMIT, + HEADER_RATELIMIT_REMAINING, + HEADER_RATELIMIT_RESET, + HEADER_RETRY_AFTER, +} from './rate-limit.service'; export { rateLimitInterceptors } from './rate-limit.interceptors'; export { withRateLimitRetry } from './rate-limit.retry'; diff --git a/src/services/common/rate-limit/rate-limit.interceptors.ts b/src/services/common/rate-limit/rate-limit.interceptors.ts index 174935d9d..36331502c 100644 --- a/src/services/common/rate-limit/rate-limit.interceptors.ts +++ b/src/services/common/rate-limit/rate-limit.interceptors.ts @@ -1,6 +1,10 @@ import { logger } from '@internxt-mobile/services/common'; import axios, { AxiosResponse, InternalAxiosRequestConfig } from 'axios'; import { + HEADER_RATELIMIT_LIMIT, + HEADER_RATELIMIT_REMAINING, + HEADER_RATELIMIT_RESET, + HEADER_RETRY_AFTER, HTTP_TOO_MANY_REQUESTS, MAX_RATE_LIMIT_RETRIES, extractEndpointKey, @@ -52,7 +56,7 @@ export const rateLimitInterceptors = [ const attempt = (axiosError.config.__rateLimitRetry ?? 0) + 1; if (attempt <= MAX_RATE_LIMIT_RETRIES) { - const retryAfter = axiosError.response.headers?.['retry-after']; + const retryAfter = axiosError.response.headers?.[HEADER_RETRY_AFTER]; const delay = rateLimitService.getRetryDelay(retryAfter, endpointKey); logRetry(attempt, delay); @@ -78,9 +82,9 @@ const logResponseHeaders = (response: AxiosResponse) => { const h = response.headers as Record; const method = response.config?.method?.toUpperCase(); const endpoint = response.config?.url || 'unknown'; - const limit = h['x-ratelimit-limit']; - const remaining = h['x-ratelimit-remaining']; - const reset = h['x-ratelimit-reset']; + const limit = h[HEADER_RATELIMIT_LIMIT]; + const remaining = h[HEADER_RATELIMIT_REMAINING]; + const reset = h[HEADER_RATELIMIT_RESET]; if (limit || remaining || reset) { logger.info(`[RateLimit] ${method} ${endpoint} → limit=${limit} remaining=${remaining} reset=${reset}`); } @@ -94,7 +98,7 @@ const logErrorHeaders = (axiosError: AxiosErrorLike) => { const status = axiosError.response.status; logger.warn( `[RateLimit] ${method} ${endpoint} → ${status} | ` + - `limit=${h['x-ratelimit-limit']} remaining=${h['x-ratelimit-remaining']} reset=${h['x-ratelimit-reset']}`, + `limit=${h[HEADER_RATELIMIT_LIMIT]} remaining=${h[HEADER_RATELIMIT_REMAINING]} reset=${h[HEADER_RATELIMIT_RESET]}`, ); }; diff --git a/src/services/common/rate-limit/rate-limit.service.ts b/src/services/common/rate-limit/rate-limit.service.ts index 5b45f2ca1..8bbf14af2 100644 --- a/src/services/common/rate-limit/rate-limit.service.ts +++ b/src/services/common/rate-limit/rate-limit.service.ts @@ -36,6 +36,12 @@ const normalizePathSegments = (pathname: string): string => { const UNKNOWN_ENDPOINT = 'unknown'; export const HTTP_TOO_MANY_REQUESTS = 429; + +export const HEADER_RATELIMIT_LIMIT = 'x-internxt-ratelimit-limit'; +export const HEADER_RATELIMIT_REMAINING = 'x-internxt-ratelimit-remaining'; +export const HEADER_RATELIMIT_RESET = 'x-internxt-ratelimit-reset'; +export const HEADER_RETRY_AFTER = 'retry-after'; + const THROTTLE_THRESHOLD = 0.4; const MAX_THROTTLE_DELAY_MS = 2000; export const MAX_RATE_LIMIT_RETRIES = 3; @@ -82,9 +88,9 @@ class RateLimitService { private readonly states = new Map(); updateFromHeaders(headers: Record, endpointKey: string): void { - const limit = this.parseHeader(headers, 'x-ratelimit-limit'); - const remaining = this.parseHeader(headers, 'x-ratelimit-remaining'); - const reset = this.parseHeader(headers, 'x-ratelimit-reset'); + const limit = this.parseHeader(headers, HEADER_RATELIMIT_LIMIT); + const remaining = this.parseHeader(headers, HEADER_RATELIMIT_REMAINING); + const reset = this.parseHeader(headers, HEADER_RATELIMIT_RESET); if (limit === null || remaining === null || reset === null) return; From de9e2aec5ea978d67d45174f211cb4adddf95734 Mon Sep 17 00:00:00 2001 From: Ramon Candel Date: Wed, 4 Feb 2026 18:19:20 +0100 Subject: [PATCH 4/4] Add rate limit error messages and enhance error handling for rate limit scenarios --- assets/lang/strings.ts | 10 +++++++- src/services/ErrorService.ts | 25 +++++++++++++++++-- .../rate-limit/rate-limit.interceptors.ts | 4 +-- .../common/rate-limit/rate-limit.retry.ts | 11 +++++--- .../common/rate-limit/rate-limit.service.ts | 4 +-- 5 files changed, 44 insertions(+), 10 deletions(-) diff --git a/assets/lang/strings.ts b/assets/lang/strings.ts index fa8f9401a..2f9e1ff0c 100644 --- a/assets/lang/strings.ts +++ b/assets/lang/strings.ts @@ -738,10 +738,14 @@ const translations = { notEnoughSpaceOnDevice: 'Not enough storage space available for download', fileAlreadyDownloading: 'File is already downloading, stopping download', genericError: 'An unexpected error occurred. Please try again.', + rateLimitReached: "This action couldn't be completed right now. Please wait a moment and try again.", + rateLimitUpload: "Your upload couldn't be completed right now. Please wait a moment and try again.", + rateLimitDownload: "Your download couldn't be completed right now. Please wait a moment and try again.", + rateLimitContent: "Content couldn't be loaded right now. Please wait a moment and try again.", }, security: { alerts: { - dontDisplayAgain: 'Don’t show this again', + dontDisplayAgain: "Don't show this again", securityWarning: { title: '⚠️ Security Notice', message: 'Security concerns detected:\n\n{0}\n\nFor better security, consider addressing these issues.', @@ -1522,6 +1526,10 @@ const translations = { notEnoughSpaceOnDevice: 'No hay suficiente espacio de almacenamiento disponible para la descarga', fileAlreadyDownloading: 'El archivo ya se está descargando, deteniendo la descarga', genericError: 'Se ha producido un error inesperado. Por favor, inténtelo de nuevo.', + rateLimitReached: 'No se ha podido completar esta acción en este momento. Por favor, espera un momento e inténtalo de nuevo.', + rateLimitUpload: 'No se ha podido completar la subida en este momento. Por favor, espera un momento e inténtalo de nuevo.', + rateLimitDownload: 'No se ha podido completar la descarga en este momento. Por favor, espera un momento e inténtalo de nuevo.', + rateLimitContent: 'No se ha podido cargar el contenido en este momento. Por favor, espera un momento e inténtalo de nuevo.', }, security: { alerts: { diff --git a/src/services/ErrorService.ts b/src/services/ErrorService.ts index 76b4c0177..0fb8c18b7 100644 --- a/src/services/ErrorService.ts +++ b/src/services/ErrorService.ts @@ -1,5 +1,6 @@ import strings from '../../assets/lang/strings'; import AppError from '../types'; +import { HTTP_TOO_MANY_REQUESTS } from './common'; import { BaseLogger } from './common/logger'; export interface GlobalErrorContext { @@ -31,7 +32,7 @@ class ErrorService { // }); } - public castError(err: unknown): AppError { + public castError(err: unknown, context?: 'upload' | 'download' | 'content'): AppError { if (err && typeof err === 'object') { const map = err as Record; @@ -43,13 +44,33 @@ class ErrorService { map.status < 600; if (isServerReturnedError) { - return new AppError(map.message as string, map.status as number); + const status = map.status as number; + + if (status === HTTP_TOO_MANY_REQUESTS) { + const message = this.getRateLimitMessage(context); + return new AppError(message, status); + } + + return new AppError(map.message as string, status); } } return new AppError(strings.errors.genericError); } + private getRateLimitMessage(context?: 'upload' | 'download' | 'content'): string { + switch (context) { + case 'upload': + return strings.errors.rateLimitUpload; + case 'download': + return strings.errors.rateLimitDownload; + case 'content': + return strings.errors.rateLimitContent; + default: + return strings.errors.rateLimitReached; + } + } + public reportError = (error: Error | unknown, context: Partial = {}) => { this.log(context.level || 'error', error); if (!__DEV__) { diff --git a/src/services/common/rate-limit/rate-limit.interceptors.ts b/src/services/common/rate-limit/rate-limit.interceptors.ts index 36331502c..2e49342ee 100644 --- a/src/services/common/rate-limit/rate-limit.interceptors.ts +++ b/src/services/common/rate-limit/rate-limit.interceptors.ts @@ -81,7 +81,7 @@ const logResponseHeaders = (response: AxiosResponse) => { if (!__DEV__) return; const h = response.headers as Record; const method = response.config?.method?.toUpperCase(); - const endpoint = response.config?.url || 'unknown'; + const endpoint = response.config?.url ?? 'unknown'; const limit = h[HEADER_RATELIMIT_LIMIT]; const remaining = h[HEADER_RATELIMIT_REMAINING]; const reset = h[HEADER_RATELIMIT_RESET]; @@ -94,7 +94,7 @@ const logErrorHeaders = (axiosError: AxiosErrorLike) => { if (!__DEV__ || !axiosError.response?.headers) return; const h = axiosError.response.headers; const method = axiosError.config?.method?.toUpperCase(); - const endpoint = axiosError.config?.url || 'unknown'; + const endpoint = axiosError.config?.url ?? 'unknown'; const status = axiosError.response.status; logger.warn( `[RateLimit] ${method} ${endpoint} → ${status} | ` + diff --git a/src/services/common/rate-limit/rate-limit.retry.ts b/src/services/common/rate-limit/rate-limit.retry.ts index 325cef1b8..6cf4791e7 100644 --- a/src/services/common/rate-limit/rate-limit.retry.ts +++ b/src/services/common/rate-limit/rate-limit.retry.ts @@ -13,8 +13,7 @@ export const withRateLimitRetry = async ( ): Promise => { let rateLimitRetries = 0; - // eslint-disable-next-line no-constant-condition - while (true) { + while (rateLimitRetries <= MAX_RATE_LIMIT_RETRIES) { try { return await operation(); } catch (err) { @@ -26,8 +25,14 @@ export const withRateLimitRetry = async ( rateLimitRetries++; const delay = rateLimitService.getRetryDelay(undefined, endpointKey); - logger.warn(`[RateLimit] ${context} 429, retry ${rateLimitRetries}/${MAX_RATE_LIMIT_RETRIES} after ${delay}ms`); + logger.warn( + `[RateLimit] ${context} 429, retry ${rateLimitRetries}/${MAX_RATE_LIMIT_RETRIES} after ${delay}ms`, + ); await new Promise((resolve) => setTimeout(resolve, delay)); } } + + // Safety net: This should never be reached due to the loop logic above. + // If somehow the loop exits without returning or throwing, we throw an explicit error. + throw new Error(`[RateLimit] ${context} exhausted retries`); }; diff --git a/src/services/common/rate-limit/rate-limit.service.ts b/src/services/common/rate-limit/rate-limit.service.ts index 8bbf14af2..916598349 100644 --- a/src/services/common/rate-limit/rate-limit.service.ts +++ b/src/services/common/rate-limit/rate-limit.service.ts @@ -61,8 +61,8 @@ const MAX_BACKOFF_MS = 5000; * → "https://gw.internxt.com/payments/display-billing" */ export const extractEndpointKey = (config: EndpointConfig): string => { - const base = config.baseURL || ''; - const path = config.url || ''; + const base = config.baseURL ?? ''; + const path = config.url ?? ''; if (!base && !path) return UNKNOWN_ENDPOINT; let fullUrl: string;