Created
October 21, 2025 15:39
-
-
Save marvinkome/d2661f42cc629b13cde53407d1609ec1 to your computer and use it in GitHub Desktop.
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
| import axios, { AxiosInstance, AxiosResponse } from 'axios' | |
| // ----------------------------------------------------------------------------- | |
| // Constants | |
| // ----------------------------------------------------------------------------- | |
| const TOKEN_EXPIRY = { | |
| ACCESS_TOKEN: 1800, | |
| REFRESH_TOKEN: 2592000, | |
| } as const | |
| const CACHE_KEYS = { | |
| ACCESS_TOKEN: 'app:access_token', | |
| REFRESH_TOKEN: 'app:refresh_token', | |
| } as const | |
| // ----------------------------------------------------------------------------- | |
| // Simple In-Memory Cache (swap out for Redis/localStorage if needed) | |
| // ----------------------------------------------------------------------------- | |
| const memoryCache = new Map<string, { value: string; expires: number }>() | |
| const getFromMemory = (key: string) => { | |
| const entry = memoryCache.get(key) | |
| if (!entry) return null | |
| if (Date.now() > entry.expires) { | |
| memoryCache.delete(key) | |
| return null | |
| } | |
| return entry.value | |
| } | |
| const setInMemory = (key: string, value: string, ttlSeconds: number) => { | |
| memoryCache.set(key, { | |
| value, | |
| expires: Date.now() + ttlSeconds * 1000, | |
| }) | |
| } | |
| const delFromMemory = (key: string) => memoryCache.delete(key) | |
| // ----------------------------------------------------------------------------- | |
| // Configuration | |
| // ----------------------------------------------------------------------------- | |
| const CONFIG = { | |
| BASE_URL: process.env.API_BASE_URL || 'https://api.example.com', | |
| } | |
| // ----------------------------------------------------------------------------- | |
| // API Instance | |
| // ----------------------------------------------------------------------------- | |
| const api: AxiosInstance = axios.create({ | |
| baseURL: CONFIG.BASE_URL, | |
| timeout: 30000, | |
| timeoutErrorMessage: 'Request timed out', | |
| }) | |
| // ----------------------------------------------------------------------------- | |
| // Token Management | |
| // ----------------------------------------------------------------------------- | |
| const tokenRefreshLock = new Map<string, Promise<string | undefined>>() | |
| const storeTokens = async (accessToken: string, refreshToken?: string) => { | |
| setInMemory(CACHE_KEYS.ACCESS_TOKEN, accessToken, TOKEN_EXPIRY.ACCESS_TOKEN) | |
| if (refreshToken) { | |
| setInMemory(CACHE_KEYS.REFRESH_TOKEN, refreshToken, TOKEN_EXPIRY.REFRESH_TOKEN) | |
| } | |
| } | |
| const clearTokens = async () => { | |
| delFromMemory(CACHE_KEYS.ACCESS_TOKEN) | |
| delFromMemory(CACHE_KEYS.REFRESH_TOKEN) | |
| } | |
| /** | |
| * Returns a valid access token or undefined if we can't get one. | |
| * Automatically refreshes if expired, with a concurrency lock. | |
| */ | |
| export const getAccessToken = async (forceRenew = false): Promise<string | undefined> => { | |
| const lockKey = 'access_token_refresh' | |
| const cachedFn = tokenRefreshLock.get(lockKey) | |
| if (cachedFn) return cachedFn | |
| const refreshPromise = (async () => { | |
| try { | |
| const cachedAccess = getFromMemory(CACHE_KEYS.ACCESS_TOKEN) | |
| if (cachedAccess && !forceRenew) return cachedAccess | |
| const refreshToken = getFromMemory(CACHE_KEYS.REFRESH_TOKEN) | |
| if (!refreshToken) { | |
| console.error('Token generation error: No refresh token found') | |
| return undefined | |
| } | |
| // Replace this with your real refresh endpoint | |
| const response = await api.post('/auth/token', { refresh_token: refreshToken }) | |
| const { access_token, refresh_token: newRefreshToken } = response.data || {} | |
| if (!access_token) { | |
| console.error('Token generation error: No access token found') | |
| await clearTokens() | |
| return undefined | |
| } | |
| await storeTokens(access_token, newRefreshToken) | |
| return access_token | |
| } catch (err) { | |
| console.error('Token generation error: Unknown error occurred', err) | |
| return undefined | |
| } finally { | |
| tokenRefreshLock.delete(lockKey) | |
| } | |
| })() | |
| tokenRefreshLock.set(lockKey, refreshPromise) | |
| return refreshPromise | |
| } | |
| // ----------------------------------------------------------------------------- | |
| // Generic API Caller with Retry + Exponential Backoff | |
| // ----------------------------------------------------------------------------- | |
| export const callApi = async <T>( | |
| endpoint: string, | |
| method: 'GET' | 'POST' | 'PUT' | 'DELETE', | |
| data?: Record<string, unknown> | FormData, | |
| options: { retryCount?: number; forceRenew?: boolean } = {} | |
| ): Promise<T> => { | |
| const { retryCount = 3, forceRenew = false } = options | |
| const cancelTokenSource = axios.CancelToken.source() | |
| try { | |
| const token = await getAccessToken(forceRenew) | |
| if (!token) throw new Error('Unable to obtain access token') | |
| const response: AxiosResponse<T> = await api.request({ | |
| url: endpoint, | |
| method, | |
| ...(data && { data }), | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| Authorization: `Bearer ${token}`, | |
| }, | |
| cancelToken: cancelTokenSource.token, | |
| }) | |
| return response.data | |
| } catch (error: unknown) { | |
| const message = err?.response?.data?.message || err.message || 'Unknown error' | |
| console.error(`API error: ${message}`) | |
| // Retry on token expiry or unauthorized errors | |
| // going to leave this here. But ideally you can just check for status code 401 | |
| // 403 usually doesn't mean an access token issue, but rather a permission issue | |
| if (/unauthorized|expired|forbidden/i.test(message) && retryCount > 1) { | |
| console.warn(`Token expired — retrying (${retryCount - 1} retries left)`) | |
| // Exponential backoff (1s, 2s, 4s) | |
| await new Promise((r) => setTimeout(r, Math.pow(2, 3 - retryCount) * 1000)) | |
| return callApi(endpoint, method, data, { retryCount: retryCount - 1, forceRenew: true }) | |
| } | |
| // in the case that max retries is reached, we want to throw the error | |
| // instead of throwing a new error. The caller doesn't know or care about the token ret | |
| throw error | |
| } finally { | |
| cancelTokenSource.cancel('Operation completed') | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment