fix(android): Use CapacitorHttp for ALL API requests in native mode
Some checks are pending
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run

This commit is contained in:
fullsizemalt 2026-01-07 20:14:57 -08:00
parent 813e4ac70c
commit 36705cd257

View file

@ -1,7 +1,9 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { Capacitor } from '@capacitor/core';
import { CapacitorHttp, HttpOptions, HttpResponse } from '@capacitor/core';
// Detect if running in Capacitor native environment
const isCapacitor = typeof window !== 'undefined' && !!(window as any).Capacitor?.isNativePlatform?.();
const isCapacitor = Capacitor.isNativePlatform();
// Production API URL for native app, relative URL for web
const API_BASE_URL = isCapacitor
@ -30,16 +32,151 @@ const processQueue = (error: unknown = null) => {
failedQueue = [];
};
const api = axios.create({
// Helper to get auth token
const getAuthToken = (): string | null => {
return localStorage.getItem('token');
};
// CapacitorHttp-based API client for native platforms
class CapacitorApiClient {
private baseURL: string;
constructor(baseURL: string) {
this.baseURL = baseURL;
}
private async request<T>(method: string, url: string, data?: unknown, retryCount = 0): Promise<{ data: T; status: number }> {
const fullUrl = url.startsWith('http') ? url : `${this.baseURL}${url}`;
const token = getAuthToken();
const options: HttpOptions = {
url: fullUrl,
method: method as HttpOptions['method'],
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
...(data ? { data } : {}),
};
try {
const response: HttpResponse = await CapacitorHttp.request(options);
// Handle 401 Unauthorized - token expired
if (response.status === 401) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then(() => this.request<T>(method, url, data));
}
isRefreshing = true;
try {
const refreshToken = localStorage.getItem('refreshToken');
if (refreshToken) {
const refreshResponse = await CapacitorHttp.post({
url: `${this.baseURL}/auth/refresh`,
headers: { 'Content-Type': 'application/json' },
data: { refreshToken },
});
if (refreshResponse.status === 200) {
localStorage.setItem('token', refreshResponse.data.accessToken);
if (refreshResponse.data.refreshToken) {
localStorage.setItem('refreshToken', refreshResponse.data.refreshToken);
}
processQueue();
return this.request<T>(method, url, data);
}
}
} catch (refreshError) {
processQueue(refreshError);
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
window.location.href = '/login';
throw refreshError;
} finally {
isRefreshing = false;
}
}
// Handle errors (4xx, 5xx)
if (response.status >= 400) {
const shouldRetry = response.status >= 500 && response.status < 600;
if (shouldRetry && retryCount < MAX_RETRIES) {
const delay = RETRY_DELAY_BASE * Math.pow(2, retryCount);
await new Promise(resolve => setTimeout(resolve, delay));
console.log(`Retrying request (${retryCount + 1}/${MAX_RETRIES}):`, url);
return this.request<T>(method, url, data, retryCount + 1);
}
// Handle rate limiting
if (response.status === 429) {
window.dispatchEvent(new CustomEvent('api:rate-limited', {
detail: { waitTime: 60000, url }
}));
}
const error = new Error(response.data?.message || `HTTP ${response.status}`);
(error as any).response = { status: response.status, data: response.data };
throw error;
}
return { data: response.data as T, status: response.status };
} catch (networkError: any) {
// Network error - retry
if (!networkError.response && retryCount < MAX_RETRIES) {
const delay = RETRY_DELAY_BASE * Math.pow(2, retryCount);
await new Promise(resolve => setTimeout(resolve, delay));
console.log(`Retrying request due to network error (${retryCount + 1}/${MAX_RETRIES}):`, url);
return this.request<T>(method, url, data, retryCount + 1);
}
throw networkError;
}
}
get<T>(url: string) {
return this.request<T>('GET', url);
}
post<T>(url: string, data?: unknown) {
return this.request<T>('POST', url, data);
}
put<T>(url: string, data?: unknown) {
return this.request<T>('PUT', url, data);
}
patch<T>(url: string, data?: unknown) {
return this.request<T>('PATCH', url, data);
}
delete<T>(url: string) {
return this.request<T>('DELETE', url);
}
}
// Create the appropriate client based on platform
let api: CapacitorApiClient | ReturnType<typeof axios.create>;
if (isCapacitor) {
// Use CapacitorHttp for native platforms
api = new CapacitorApiClient(API_BASE_URL);
} else {
// Use axios for web
const axiosInstance = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
timeout: 30000, // 30 second timeout
timeout: 30000,
});
// Request interceptor - add auth token
api.interceptors.request.use((config) => {
axiosInstance.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
@ -48,7 +185,7 @@ api.interceptors.request.use((config) => {
});
// Response interceptor - handle errors and retries
api.interceptors.response.use(
axiosInstance.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retryCount?: number };
@ -59,14 +196,12 @@ api.interceptors.response.use(
// Handle 401 Unauthorized - token expired
if (error.response?.status === 401) {
// If already refreshing, queue this request
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then(() => api(originalRequest));
}).then(() => axiosInstance(originalRequest));
}
// Try to refresh token
isRefreshing = true;
try {
@ -83,11 +218,10 @@ api.interceptors.response.use(
}
processQueue();
return api(originalRequest);
return axiosInstance(originalRequest);
}
} catch (refreshError) {
processQueue(refreshError);
// Clear tokens and redirect to login
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
@ -100,8 +234,8 @@ api.interceptors.response.use(
// Retry logic for network errors and 5xx errors
const shouldRetry = (
!error.response || // Network error
(error.response.status >= 500 && error.response.status < 600) // Server error
!error.response ||
(error.response.status >= 500 && error.response.status < 600)
);
if (shouldRetry) {
@ -109,23 +243,17 @@ api.interceptors.response.use(
if (originalRequest._retryCount < MAX_RETRIES) {
originalRequest._retryCount++;
// Exponential backoff: 1s, 2s, 4s
const delay = RETRY_DELAY_BASE * Math.pow(2, originalRequest._retryCount - 1);
await new Promise(resolve => setTimeout(resolve, delay));
console.log(`Retrying request (${originalRequest._retryCount}/${MAX_RETRIES}):`, originalRequest.url);
return api(originalRequest);
return axiosInstance(originalRequest);
}
}
// Handle rate limiting (429)
if (error.response?.status === 429) {
const retryAfter = error.response.headers['retry-after'];
const waitTime = retryAfter ? parseInt(retryAfter, 10) * 1000 : 60000;
// Dispatch event so UI can show a message
const waitTime = retryAfter ? parseInt(retryAfter as string, 10) * 1000 : 60000;
window.dispatchEvent(new CustomEvent('api:rate-limited', {
detail: { waitTime, url: originalRequest.url }
}));
@ -135,5 +263,7 @@ api.interceptors.response.use(
}
);
export default api;
api = axiosInstance;
}
export default api;