Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save EmekaManuel/6bc6fd5ea69df0cd85982ee1038c57ed to your computer and use it in GitHub Desktop.
Save EmekaManuel/6bc6fd5ea69df0cd85982ee1038c57ed to your computer and use it in GitHub Desktop.
Advanced Typescript Axios Interceptor with [JWT] Authentication, Error Handling, Retry Mechanism and API request helper methods : This TypeScript-based API wrapper extends Axios to handle authentication, token expiration, request retries, and network monitoring. It includes: JWT Authentication: Token decoding, expiration checks, and automatic lo…
/* eslint-disable @typescript-eslint/no-explicit-any */
import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
import { jwtDecode } from 'jwt-decode';
// Define interfaces
interface DecodedToken {
roles: string[];
sub: string; // email
iat: number; // issued at timestamp
exp: number; // expiry timestamp
}
interface AuthError extends Error {
code?: string;
status?: number;
isAuthError: boolean;
}
// Configuration
const AUTH_CONFIG = {
tokenExpiryBuffer: 30, // Seconds before expiry to consider token invalid
storageKey: {
accessToken: 'accessToken',
},
loginRedirectPath: '/login',
maxRetries: 3,
retryDelay: 1000, // Base delay in ms before retrying
};
// Create axios instance with timeout
const api = axios.create({
baseURL: 'https://api.example.com',
headers: {
'Content-Type': 'application/json',
},
timeout: 10000, // 10 second timeout
});
// Create custom auth error
const createAuthError = (message: string, code?: string, status?: number): AuthError => {
const error = new Error(message) as AuthError;
error.code = code;
error.status = status;
error.isAuthError = true;
return error;
};
// Token handling functions
export const decodeToken = (token: string): DecodedToken | null => {
if (!token) return null;
try {
return jwtDecode<DecodedToken>(token);
} catch (error) {
console.error('Failed to decode token:', error);
return null;
}
};
export const isTokenExpired = (token: string): boolean => {
const decoded = decodeToken(token);
if (!decoded) return true;
// Get current time in seconds (JWT exp is in seconds)
const currentTime = Math.floor(Date.now() / 1000);
// Add buffer to handle latency
return decoded.exp <= currentTime + AUTH_CONFIG.tokenExpiryBuffer;
};
export const getTokenInfo = () => {
try {
const accessToken = localStorage.getItem(AUTH_CONFIG.storageKey.accessToken);
if (!accessToken) return null;
const decoded = decodeToken(accessToken);
if (!decoded) return null;
return {
roles: decoded.roles || [],
email: decoded.sub,
isExpired: isTokenExpired(accessToken),
expiresAt: decoded.exp,
// Calculate remaining time in seconds
remainingTime: decoded.exp - Math.floor(Date.now() / 1000),
};
} catch (error) {
console.error('Error getting token info:', error);
return null;
}
};
export const getUserRoles = (): string[] => {
return getTokenInfo()?.roles || [];
};
export const getUserEmail = (): string | null => {
return getTokenInfo()?.email || null;
};
export const clearAuthData = (): void => {
localStorage.removeItem(AUTH_CONFIG.storageKey.accessToken);
// Clear other auth-related data if needed
};
export const logOut = (redirectUrl = AUTH_CONFIG.loginRedirectPath): void => {
clearAuthData();
// Prevent redirect loops
if (!window.location.pathname.includes(redirectUrl)) {
window.location.href = redirectUrl;
}
};
// Session expiry warning
export const getSessionExpiryWarning = (): { shouldWarn: boolean; remainingMinutes: number } => {
const tokenInfo = getTokenInfo();
if (!tokenInfo) return { shouldWarn: false, remainingMinutes: 0 };
// Warn if less than 5 minutes remaining
const remainingMinutes = Math.floor(tokenInfo.remainingTime / 60);
return {
shouldWarn: remainingMinutes < 5 && remainingMinutes > 0,
remainingMinutes,
};
};
// Network status monitoring
let isOnline = navigator.onLine;
window.addEventListener('online', () => {
isOnline = true;
});
window.addEventListener('offline', () => {
isOnline = false;
});
// Retry mechanism
const retryRequest = async (config: AxiosRequestConfig, retryCount = 0): Promise<AxiosResponse> => {
try {
// Wait before retrying with exponential backoff
const delay = Math.min(
AUTH_CONFIG.retryDelay * Math.pow(2, retryCount),
10000 // Max 10 seconds
);
await new Promise((resolve) => setTimeout(resolve, delay));
return await api(config);
} catch (error) {
if (retryCount < AUTH_CONFIG.maxRetries && isOnline && axios.isAxiosError(error) && (error.code === 'ECONNABORTED' || !error.response || error.response.status >= 500)) {
console.warn(`Retrying request (${retryCount + 1}/${AUTH_CONFIG.maxRetries})`, config);
return retryRequest(config, retryCount + 1);
}
throw error;
}
};
// Cache for failed requests that might be retried after login
const pendingRequests: AxiosRequestConfig[] = [];
export const retryPendingRequests = (): void => {
const requests = [...pendingRequests];
pendingRequests.length = 0; // Clear the array
requests.forEach((config) => {
api(config).catch((error) => {
console.error('Failed to retry request:', error);
});
});
};
// Request interceptor
api.interceptors.request.use(
(config) => {
// Check network connectivity
if (!isOnline) {
throw createAuthError('No internet connection', 'network/offline');
}
const accessToken = localStorage.getItem(AUTH_CONFIG.storageKey.accessToken);
// If no token exists, proceed without authorization header
if (!accessToken) {
return config;
}
// Check if token is expired and redirect to login if needed
if (isTokenExpired(accessToken)) {
// Store the request for potential retry after login
if (config.method?.toLowerCase() === 'get') {
pendingRequests.push(config);
}
// Redirect to login page
clearAuthData();
logOut();
throw createAuthError('Token expired. Please log in again.', 'auth/token-expired');
}
// Token is still valid, use it
config.headers.Authorization = `Bearer ${accessToken}`;
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor
api.interceptors.response.use(
(response) => {
return response;
},
async (error: AxiosError) => {
const originalRequest = error.config;
// No config means this isn't a request error
if (!originalRequest) {
return Promise.reject(error);
}
// Network errors or timeout - retry if within limits
if (error.code === 'ECONNABORTED' || !error.response) {
try {
return await retryRequest(originalRequest);
} catch (error) {
// Fixed: Removed unused variable 'retryError'
return Promise.reject(createAuthError('Network error: Unable to connect to server', 'network/connection-failed', error));
}
}
// Handle 401 Unauthorized - redirect to login
if (error.response?.status === 401) {
// Store the request for potential retry after login
if (originalRequest.method?.toLowerCase() === 'get') {
pendingRequests.push(originalRequest);
}
clearAuthData();
logOut();
return Promise.reject(createAuthError('Authentication failed. Please log in again.', 'auth/unauthorized', 401));
}
// Handle 403 Forbidden (permission issues)
if (error.response?.status === 403) {
return Promise.reject(createAuthError('You do not have permission to access this resource', 'auth/forbidden', 403));
}
// Handle 500+ server errors
if (error.response?.status >= 500) {
try {
return await retryRequest(originalRequest);
} catch (error) {
// Fixed: Removed unused variable 'retryError'
return Promise.reject(createAuthError('Server error. Please try again later.', 'server/error', error.response?.status));
}
}
return Promise.reject(error);
}
);
// Helper methods for API requests with better error handling
export const apiRequest = {
get: async <T>(url: string, config?: AxiosRequestConfig): Promise<T> => {
try {
const response = await api.get<T>(url, config);
return response.data;
} catch (error) {
handleApiError(error);
throw error;
}
},
post: async <T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> => {
try {
const response = await api.post<T>(url, data, config);
return response.data;
} catch (error) {
handleApiError(error);
throw error;
}
},
put: async <T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> => {
try {
const response = await api.put<T>(url, data, config);
return response.data;
} catch (error) {
handleApiError(error);
throw error;
}
},
delete: async <T>(url: string, config?: AxiosRequestConfig): Promise<T> => {
try {
const response = await api.delete<T>(url, config);
return response.data;
} catch (error) {
handleApiError(error);
throw error;
}
},
};
// Centralized error handling
const handleApiError = (error: unknown): never => {
// Network or connection errors
if (!navigator.onLine) {
throw createAuthError('No internet connection. Please check your network.', 'network/offline');
}
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const data = error.response?.data;
// Handle specific error codes
switch (status) {
case 400:
throw createAuthError(data?.message || 'Invalid request', 'request/invalid', status);
case 401:
case 403:
// Already handled in interceptor
throw error;
case 404:
throw createAuthError('Resource not found', 'request/not-found', status);
case 408:
case 429:
throw createAuthError(status === 408 ? 'Request timeout' : 'Too many requests', status === 408 ? 'request/timeout' : 'request/rate-limited', status);
case 500:
case 502:
case 503:
case 504:
throw createAuthError('Server error. Please try again later.', 'server/error', status);
default:
throw createAuthError(data?.message || error.message || 'Unknown error occurred', `request/error-${status || 'unknown'}`, status);
}
}
// Non-axios errors
throw createAuthError(error instanceof Error ? error.message : 'Unknown error occurred', 'app/unknown-error');
};
// Public API
export default {
...api,
apiRequest,
auth: {
getUserRoles,
getUserEmail,
getTokenInfo,
logOut,
clearAuthData,
getSessionExpiryWarning,
retryPendingRequests,
},
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment