Last active
March 14, 2025 09:19
-
-
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…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* 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