Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion assets/lang/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: 'Dont 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.',
Expand Down Expand Up @@ -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: {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 13 additions & 11 deletions src/network/upload.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -32,18 +33,19 @@ export async function uploadFile(
async function retryUpload(): Promise<string> {
const MAX_TRIES = 3;
const RETRY_DELAY = 1000;
let uploadPromise: Promise<string>;
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,
});
Expand All @@ -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);

Expand Down
44 changes: 44 additions & 0 deletions src/plugins/RateLimitPlugin.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>, endpointKey);
}
return response;
},
(error) => {
if (error.response?.headers) {
const endpointKey = extractEndpointKey(error.config);
rateLimitService.updateFromHeaders(error.response.headers as Record<string, string>, endpointKey);
}
return Promise.reject(error);
},
);
},
};

export default rateLimitPlugin;
3 changes: 2 additions & 1 deletion src/plugins/index.ts
Original file line number Diff line number Diff line change
@@ -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;
25 changes: 23 additions & 2 deletions src/services/ErrorService.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<string, unknown>;

Expand All @@ -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<ErrorContext> = {}) => {
this.log(context.level || 'error', error);
if (!__DEV__) {
Expand Down
1 change: 1 addition & 0 deletions src/services/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './errors';
export * from './media';
export * from './filesystem';
export * from './biometrics';
export * from './rate-limit';
12 changes: 12 additions & 0 deletions src/services/common/rate-limit/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
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';
111 changes: 111 additions & 0 deletions src/services/common/rate-limit/rate-limit.interceptors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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,
rateLimitService,
} from './rate-limit.service';

interface AxiosErrorLike {
response?: { status?: number; headers?: Record<string, string> };
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<InternalAxiosRequestConfig> => {
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<string, string>, endpointKey);
}
return response;
},
onRejected: async (error: unknown): Promise<AxiosResponse> => {
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?.[HEADER_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<string, string>;
const method = response.config?.method?.toUpperCase();
const endpoint = response.config?.url ?? 'unknown';
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}`);
}
};

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[HEADER_RATELIMIT_LIMIT]} remaining=${h[HEADER_RATELIMIT_REMAINING]} reset=${h[HEADER_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`);
};
38 changes: 38 additions & 0 deletions src/services/common/rate-limit/rate-limit.retry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
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 <T>(
operation: () => Promise<T>,
context: string,
endpointKey?: string,
): Promise<T> => {
let rateLimitRetries = 0;

while (rateLimitRetries <= MAX_RATE_LIMIT_RETRIES) {
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));
}
}

// 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`);
};
Loading
Loading