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 axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { Capacitor } from '@capacitor/core';
import { CapacitorHttp, HttpOptions, HttpResponse } from '@capacitor/core';
// Detect if running in Capacitor native environment // 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 // Production API URL for native app, relative URL for web
const API_BASE_URL = isCapacitor const API_BASE_URL = isCapacitor
@ -30,25 +32,160 @@ const processQueue = (error: unknown = null) => {
failedQueue = []; 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, baseURL: API_BASE_URL,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
timeout: 30000, // 30 second timeout timeout: 30000,
}); });
// Request interceptor - add auth token // Request interceptor - add auth token
api.interceptors.request.use((config) => { axiosInstance.interceptors.request.use((config) => {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
} }
return config; return config;
}); });
// Response interceptor - handle errors and retries // Response interceptor - handle errors and retries
api.interceptors.response.use( axiosInstance.interceptors.response.use(
(response) => response, (response) => response,
async (error: AxiosError) => { async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retryCount?: number }; const originalRequest = error.config as InternalAxiosRequestConfig & { _retryCount?: number };
@ -59,14 +196,12 @@ api.interceptors.response.use(
// Handle 401 Unauthorized - token expired // Handle 401 Unauthorized - token expired
if (error.response?.status === 401) { if (error.response?.status === 401) {
// If already refreshing, queue this request
if (isRefreshing) { if (isRefreshing) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject }); failedQueue.push({ resolve, reject });
}).then(() => api(originalRequest)); }).then(() => axiosInstance(originalRequest));
} }
// Try to refresh token
isRefreshing = true; isRefreshing = true;
try { try {
@ -83,11 +218,10 @@ api.interceptors.response.use(
} }
processQueue(); processQueue();
return api(originalRequest); return axiosInstance(originalRequest);
} }
} catch (refreshError) { } catch (refreshError) {
processQueue(refreshError); processQueue(refreshError);
// Clear tokens and redirect to login
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('refreshToken'); localStorage.removeItem('refreshToken');
localStorage.removeItem('user'); localStorage.removeItem('user');
@ -100,8 +234,8 @@ api.interceptors.response.use(
// Retry logic for network errors and 5xx errors // Retry logic for network errors and 5xx errors
const shouldRetry = ( const shouldRetry = (
!error.response || // Network error !error.response ||
(error.response.status >= 500 && error.response.status < 600) // Server error (error.response.status >= 500 && error.response.status < 600)
); );
if (shouldRetry) { if (shouldRetry) {
@ -109,23 +243,17 @@ api.interceptors.response.use(
if (originalRequest._retryCount < MAX_RETRIES) { if (originalRequest._retryCount < MAX_RETRIES) {
originalRequest._retryCount++; originalRequest._retryCount++;
// Exponential backoff: 1s, 2s, 4s
const delay = RETRY_DELAY_BASE * Math.pow(2, originalRequest._retryCount - 1); const delay = RETRY_DELAY_BASE * Math.pow(2, originalRequest._retryCount - 1);
await new Promise(resolve => setTimeout(resolve, delay)); await new Promise(resolve => setTimeout(resolve, delay));
console.log(`Retrying request (${originalRequest._retryCount}/${MAX_RETRIES}):`, originalRequest.url); console.log(`Retrying request (${originalRequest._retryCount}/${MAX_RETRIES}):`, originalRequest.url);
return api(originalRequest); return axiosInstance(originalRequest);
} }
} }
// Handle rate limiting (429) // Handle rate limiting (429)
if (error.response?.status === 429) { if (error.response?.status === 429) {
const retryAfter = error.response.headers['retry-after']; const retryAfter = error.response.headers['retry-after'];
const waitTime = retryAfter ? parseInt(retryAfter, 10) * 1000 : 60000; const waitTime = retryAfter ? parseInt(retryAfter as string, 10) * 1000 : 60000;
// Dispatch event so UI can show a message
window.dispatchEvent(new CustomEvent('api:rate-limited', { window.dispatchEvent(new CustomEvent('api:rate-limited', {
detail: { waitTime, url: originalRequest.url } detail: { waitTime, url: originalRequest.url }
})); }));
@ -133,7 +261,9 @@ api.interceptors.response.use(
return Promise.reject(error); return Promise.reject(error);
} }
); );
api = axiosInstance;
}
export default api; export default api;