|
|
import axios, { AxiosInstance, InternalAxiosRequestConfig } from 'axios'; |
|
|
|
|
|
|
|
|
const MAX_RETRIES = 2; |
|
|
const RETRY_DELAY = 1000; |
|
|
const FAILURE_THRESHOLD = 5; |
|
|
const RESET_TIMEOUT = 30000; |
|
|
const RATE_LIMIT_DELAY = 100; |
|
|
|
|
|
interface CustomConfig extends InternalAxiosRequestConfig { |
|
|
_retryCount?: number; |
|
|
} |
|
|
|
|
|
|
|
|
const circuitStates: Record<string, { |
|
|
status: 'CLOSED' | 'OPEN' | 'HALF_OPEN'; |
|
|
failures: number; |
|
|
lastFailure?: number; |
|
|
lastRequest?: number; |
|
|
}> = {}; |
|
|
|
|
|
function getHost(url?: string): string { |
|
|
if (!url) return 'unknown'; |
|
|
try { |
|
|
return new URL(url).hostname; |
|
|
} catch { |
|
|
return url; |
|
|
} |
|
|
} |
|
|
|
|
|
export const httpClient: AxiosInstance = axios.create({ |
|
|
timeout: 10000, |
|
|
headers: { |
|
|
'Content-Type': 'application/json', |
|
|
}, |
|
|
}); |
|
|
|
|
|
|
|
|
httpClient.interceptors.request.use( |
|
|
async (config) => { |
|
|
const host = getHost(config.url); |
|
|
|
|
|
if (!circuitStates[host]) { |
|
|
circuitStates[host] = { status: 'CLOSED', failures: 0 }; |
|
|
} |
|
|
|
|
|
const state = circuitStates[host]; |
|
|
|
|
|
|
|
|
if (state.status === 'OPEN') { |
|
|
const now = Date.now(); |
|
|
if (now - (state.lastFailure || 0) > RESET_TIMEOUT) { |
|
|
state.status = 'HALF_OPEN'; |
|
|
} else { |
|
|
throw new Error(`Circuit breaker is OPEN for ${host}`); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const now = Date.now(); |
|
|
if (state.lastRequest && (now - state.lastRequest < RATE_LIMIT_DELAY)) { |
|
|
const waitTime = RATE_LIMIT_DELAY - (now - state.lastRequest); |
|
|
await new Promise(resolve => setTimeout(resolve, waitTime)); |
|
|
} |
|
|
state.lastRequest = Date.now(); |
|
|
|
|
|
return config; |
|
|
}, |
|
|
(error) => Promise.reject(error) |
|
|
); |
|
|
|
|
|
|
|
|
httpClient.interceptors.response.use( |
|
|
(response) => { |
|
|
const host = getHost(response.config.url); |
|
|
if (circuitStates[host]) { |
|
|
circuitStates[host].failures = 0; |
|
|
circuitStates[host].status = 'CLOSED'; |
|
|
} |
|
|
return response; |
|
|
}, |
|
|
async (error) => { |
|
|
const config = error.config as CustomConfig; |
|
|
const host = getHost(config?.url); |
|
|
|
|
|
if (!circuitStates[host]) { |
|
|
circuitStates[host] = { status: 'CLOSED', failures: 0 }; |
|
|
} |
|
|
|
|
|
const state = circuitStates[host]; |
|
|
|
|
|
|
|
|
state.failures += 1; |
|
|
state.lastFailure = Date.now(); |
|
|
|
|
|
if (state.failures >= FAILURE_THRESHOLD) { |
|
|
state.status = 'OPEN'; |
|
|
} |
|
|
|
|
|
|
|
|
if (config && (error.response?.status === 503 || !error.response)) { |
|
|
config._retryCount = config._retryCount || 0; |
|
|
|
|
|
if (config._retryCount < MAX_RETRIES) { |
|
|
config._retryCount += 1; |
|
|
const delay = RETRY_DELAY * config._retryCount; |
|
|
|
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, delay)); |
|
|
return httpClient(config); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (error.response?.status === 503) { |
|
|
error.message = 'Service temporarily unavailable'; |
|
|
} else if (error.code === 'ECONNABORTED') { |
|
|
error.message = 'Request timed out'; |
|
|
} |
|
|
|
|
|
return Promise.reject(error); |
|
|
} |
|
|
); |
|
|
|