|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SelfHealingAPIHub {
|
|
|
constructor(config = {}) {
|
|
|
this.config = {
|
|
|
retryAttempts: config.retryAttempts || 3,
|
|
|
retryDelay: config.retryDelay || 1000,
|
|
|
healthCheckInterval: config.healthCheckInterval || 60000,
|
|
|
cacheExpiry: config.cacheExpiry || 300000,
|
|
|
backendUrl: config.backendUrl || '/api',
|
|
|
enableAutoRecovery: config.enableAutoRecovery !== false,
|
|
|
enableCaching: config.enableCaching !== false,
|
|
|
...config
|
|
|
};
|
|
|
|
|
|
this.cache = new Map();
|
|
|
this.healthStatus = new Map();
|
|
|
this.failedEndpoints = new Map();
|
|
|
this.activeRecoveries = new Set();
|
|
|
|
|
|
if (this.config.enableAutoRecovery) {
|
|
|
this.startHealthMonitoring();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
startHealthMonitoring() {
|
|
|
console.log('🏥 Self-Healing System: Health monitoring started');
|
|
|
|
|
|
setInterval(() => {
|
|
|
this.performHealthChecks();
|
|
|
this.cleanupFailedEndpoints();
|
|
|
this.cleanupExpiredCache();
|
|
|
}, this.config.healthCheckInterval);
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async performHealthChecks() {
|
|
|
const endpoints = this.getRegisteredEndpoints();
|
|
|
|
|
|
for (const endpoint of endpoints) {
|
|
|
if (!this.activeRecoveries.has(endpoint)) {
|
|
|
await this.checkEndpointHealth(endpoint);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async checkEndpointHealth(endpoint) {
|
|
|
try {
|
|
|
const response = await this.fetchWithTimeout(endpoint, {
|
|
|
method: 'HEAD',
|
|
|
timeout: 5000
|
|
|
});
|
|
|
|
|
|
this.healthStatus.set(endpoint, {
|
|
|
status: response.ok ? 'healthy' : 'degraded',
|
|
|
lastCheck: Date.now(),
|
|
|
responseTime: response.headers.get('X-Response-Time') || 'N/A'
|
|
|
});
|
|
|
|
|
|
if (response.ok && this.failedEndpoints.has(endpoint)) {
|
|
|
console.log(`✅ Self-Healing: Endpoint recovered: ${endpoint}`);
|
|
|
this.failedEndpoints.delete(endpoint);
|
|
|
}
|
|
|
|
|
|
return response.ok;
|
|
|
} catch (error) {
|
|
|
this.healthStatus.set(endpoint, {
|
|
|
status: 'unhealthy',
|
|
|
lastCheck: Date.now(),
|
|
|
error: error.message
|
|
|
});
|
|
|
|
|
|
this.recordFailure(endpoint, error);
|
|
|
return false;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async fetchWithRecovery(url, options = {}) {
|
|
|
const cacheKey = `${options.method || 'GET'}:${url}`;
|
|
|
|
|
|
|
|
|
if (this.config.enableCaching && options.method === 'GET') {
|
|
|
const cached = this.getFromCache(cacheKey);
|
|
|
if (cached) {
|
|
|
console.log(`💾 Using cached data for: ${url}`);
|
|
|
return cached;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
for (let attempt = 1; attempt <= this.config.retryAttempts; attempt++) {
|
|
|
try {
|
|
|
const response = await this.fetchWithTimeout(url, options);
|
|
|
|
|
|
if (response.ok) {
|
|
|
const data = await response.json();
|
|
|
|
|
|
|
|
|
if (this.config.enableCaching && options.method === 'GET') {
|
|
|
this.setCache(cacheKey, data);
|
|
|
}
|
|
|
|
|
|
|
|
|
if (this.failedEndpoints.has(url)) {
|
|
|
console.log(`✅ Self-Healing: Recovery successful for ${url}`);
|
|
|
this.failedEndpoints.delete(url);
|
|
|
}
|
|
|
|
|
|
return { success: true, data, source: 'primary' };
|
|
|
}
|
|
|
|
|
|
|
|
|
if (attempt === this.config.retryAttempts) {
|
|
|
return await this.tryFallback(url, options);
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
console.warn(`⚠️ Attempt ${attempt}/${this.config.retryAttempts} failed for ${url}:`, error.message);
|
|
|
|
|
|
if (attempt < this.config.retryAttempts) {
|
|
|
|
|
|
await this.delay(this.config.retryDelay * Math.pow(2, attempt - 1));
|
|
|
} else {
|
|
|
|
|
|
return await this.tryFallback(url, options, error);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
return this.handleFailure(url, options);
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async tryFallback(primaryUrl, options = {}, primaryError = null) {
|
|
|
console.log(`🔄 Self-Healing: Attempting fallback for ${primaryUrl}`);
|
|
|
|
|
|
const fallbacks = this.getFallbackEndpoints(primaryUrl);
|
|
|
|
|
|
for (const fallbackUrl of fallbacks) {
|
|
|
try {
|
|
|
const response = await this.fetchWithTimeout(fallbackUrl, options);
|
|
|
|
|
|
if (response.ok) {
|
|
|
const data = await response.json();
|
|
|
console.log(`✅ Self-Healing: Fallback successful using ${fallbackUrl}`);
|
|
|
|
|
|
|
|
|
const cacheKey = `${options.method || 'GET'}:${primaryUrl}`;
|
|
|
this.setCache(cacheKey, data);
|
|
|
|
|
|
return { success: true, data, source: 'fallback', fallbackUrl };
|
|
|
}
|
|
|
} catch (error) {
|
|
|
console.warn(`⚠️ Fallback attempt failed for ${fallbackUrl}:`, error.message);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
return await this.tryBackendProxy(primaryUrl, options, primaryError);
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async tryBackendProxy(url, options = {}, originalError = null) {
|
|
|
console.log(`🔄 Self-Healing: Attempting backend proxy for ${url}`);
|
|
|
|
|
|
try {
|
|
|
const proxyUrl = `${this.config.backendUrl}/proxy`;
|
|
|
const response = await fetch(proxyUrl, {
|
|
|
method: 'POST',
|
|
|
headers: {
|
|
|
'Content-Type': 'application/json',
|
|
|
},
|
|
|
body: JSON.stringify({
|
|
|
url,
|
|
|
method: options.method || 'GET',
|
|
|
headers: options.headers || {},
|
|
|
body: options.body
|
|
|
})
|
|
|
});
|
|
|
|
|
|
if (response.ok) {
|
|
|
const data = await response.json();
|
|
|
console.log(`✅ Self-Healing: Backend proxy successful`);
|
|
|
return { success: true, data, source: 'backend-proxy' };
|
|
|
}
|
|
|
} catch (error) {
|
|
|
console.error(`❌ Backend proxy failed:`, error);
|
|
|
}
|
|
|
|
|
|
|
|
|
const cacheKey = `${options.method || 'GET'}:${url}`;
|
|
|
const cached = this.getFromCache(cacheKey, true);
|
|
|
|
|
|
if (cached) {
|
|
|
console.log(`💾 Self-Healing: Using stale cache as last resort`);
|
|
|
return { success: true, data: cached, source: 'stale-cache', warning: 'Data may be outdated' };
|
|
|
}
|
|
|
|
|
|
return this.handleFailure(url, options, originalError);
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
handleFailure(url, options, error) {
|
|
|
this.recordFailure(url, error);
|
|
|
|
|
|
return {
|
|
|
success: false,
|
|
|
error: error?.message || 'All recovery attempts failed',
|
|
|
url,
|
|
|
timestamp: Date.now(),
|
|
|
recoveryAttempts: this.config.retryAttempts,
|
|
|
suggestions: this.getRecoverySuggestions(url)
|
|
|
};
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
recordFailure(endpoint, error) {
|
|
|
if (!this.failedEndpoints.has(endpoint)) {
|
|
|
this.failedEndpoints.set(endpoint, {
|
|
|
count: 0,
|
|
|
firstFailure: Date.now(),
|
|
|
errors: []
|
|
|
});
|
|
|
}
|
|
|
|
|
|
const record = this.failedEndpoints.get(endpoint);
|
|
|
record.count++;
|
|
|
record.lastFailure = Date.now();
|
|
|
record.errors.push({
|
|
|
timestamp: Date.now(),
|
|
|
message: error?.message || 'Unknown error'
|
|
|
});
|
|
|
|
|
|
|
|
|
if (record.errors.length > 10) {
|
|
|
record.errors = record.errors.slice(-10);
|
|
|
}
|
|
|
|
|
|
console.error(`❌ Endpoint failure recorded: ${endpoint} (${record.count} failures)`);
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getRecoverySuggestions(url) {
|
|
|
return [
|
|
|
'Check your internet connection',
|
|
|
'Verify API key is valid and not expired',
|
|
|
'Check if API service is operational',
|
|
|
'Try again in a few moments',
|
|
|
'Consider using alternative data sources'
|
|
|
];
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getFallbackEndpoints(url) {
|
|
|
const fallbacks = [];
|
|
|
|
|
|
|
|
|
const fallbackMap = {
|
|
|
'etherscan.io': ['blockchair.com/ethereum', 'ethplorer.io'],
|
|
|
'bscscan.com': ['api.bscscan.com'],
|
|
|
'coingecko.com': ['api.coinpaprika.com', 'api.coincap.io'],
|
|
|
'coinmarketcap.com': ['api.coingecko.com', 'api.coinpaprika.com'],
|
|
|
'cryptopanic.com': ['newsapi.org'],
|
|
|
};
|
|
|
|
|
|
|
|
|
for (const [primary, alternatives] of Object.entries(fallbackMap)) {
|
|
|
if (url.includes(primary)) {
|
|
|
|
|
|
alternatives.forEach(alt => {
|
|
|
const fallbackUrl = this.transformToFallback(url, alt);
|
|
|
if (fallbackUrl) fallbacks.push(fallbackUrl);
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return fallbacks;
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
transformToFallback(originalUrl, fallbackBase) {
|
|
|
|
|
|
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getRegisteredEndpoints() {
|
|
|
|
|
|
return Array.from(this.healthStatus.keys());
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async fetchWithTimeout(url, options = {}) {
|
|
|
const timeout = options.timeout || 10000;
|
|
|
const controller = new AbortController();
|
|
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
|
|
|
|
try {
|
|
|
const response = await fetch(url, {
|
|
|
...options,
|
|
|
signal: controller.signal
|
|
|
});
|
|
|
clearTimeout(timeoutId);
|
|
|
return response;
|
|
|
} catch (error) {
|
|
|
clearTimeout(timeoutId);
|
|
|
if (error.name === 'AbortError') {
|
|
|
throw new Error(`Request timeout after ${timeout}ms`);
|
|
|
}
|
|
|
throw error;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
setCache(key, data) {
|
|
|
this.cache.set(key, {
|
|
|
data,
|
|
|
timestamp: Date.now(),
|
|
|
expiry: Date.now() + this.config.cacheExpiry
|
|
|
});
|
|
|
}
|
|
|
|
|
|
getFromCache(key, allowExpired = false) {
|
|
|
const cached = this.cache.get(key);
|
|
|
if (!cached) return null;
|
|
|
|
|
|
if (allowExpired || cached.expiry > Date.now()) {
|
|
|
return cached.data;
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
cleanupExpiredCache() {
|
|
|
const now = Date.now();
|
|
|
for (const [key, value] of this.cache.entries()) {
|
|
|
if (value.expiry < now) {
|
|
|
this.cache.delete(key);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
cleanupFailedEndpoints() {
|
|
|
const maxAge = 3600000;
|
|
|
const now = Date.now();
|
|
|
|
|
|
for (const [endpoint, record] of this.failedEndpoints.entries()) {
|
|
|
if (now - record.lastFailure > maxAge) {
|
|
|
console.log(`🧹 Cleaning up old failure record: ${endpoint}`);
|
|
|
this.failedEndpoints.delete(endpoint);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getHealthStatus() {
|
|
|
const total = this.healthStatus.size;
|
|
|
const healthy = Array.from(this.healthStatus.values()).filter(s => s.status === 'healthy').length;
|
|
|
const degraded = Array.from(this.healthStatus.values()).filter(s => s.status === 'degraded').length;
|
|
|
const unhealthy = Array.from(this.healthStatus.values()).filter(s => s.status === 'unhealthy').length;
|
|
|
|
|
|
return {
|
|
|
total,
|
|
|
healthy,
|
|
|
degraded,
|
|
|
unhealthy,
|
|
|
healthPercentage: total > 0 ? Math.round((healthy / total) * 100) : 0,
|
|
|
failedEndpoints: this.failedEndpoints.size,
|
|
|
cacheSize: this.cache.size,
|
|
|
lastCheck: Date.now()
|
|
|
};
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
delay(ms) {
|
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async triggerRecovery(endpoint) {
|
|
|
console.log(`🔧 Manual recovery triggered for: ${endpoint}`);
|
|
|
this.activeRecoveries.add(endpoint);
|
|
|
|
|
|
try {
|
|
|
const isHealthy = await this.checkEndpointHealth(endpoint);
|
|
|
if (isHealthy) {
|
|
|
this.failedEndpoints.delete(endpoint);
|
|
|
return { success: true, message: 'Endpoint recovered' };
|
|
|
} else {
|
|
|
return { success: false, message: 'Endpoint still unhealthy' };
|
|
|
}
|
|
|
} finally {
|
|
|
this.activeRecoveries.delete(endpoint);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getDiagnostics() {
|
|
|
return {
|
|
|
health: this.getHealthStatus(),
|
|
|
failedEndpoints: Array.from(this.failedEndpoints.entries()).map(([url, record]) => ({
|
|
|
url,
|
|
|
...record
|
|
|
})),
|
|
|
cache: {
|
|
|
size: this.cache.size,
|
|
|
entries: Array.from(this.cache.keys())
|
|
|
},
|
|
|
config: {
|
|
|
retryAttempts: this.config.retryAttempts,
|
|
|
retryDelay: this.config.retryDelay,
|
|
|
healthCheckInterval: this.config.healthCheckInterval,
|
|
|
cacheExpiry: this.config.cacheExpiry,
|
|
|
enableAutoRecovery: this.config.enableAutoRecovery,
|
|
|
enableCaching: this.config.enableCaching
|
|
|
}
|
|
|
};
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
|
module.exports = SelfHealingAPIHub;
|
|
|
}
|
|
|
|