351 lines
9.7 KiB
TypeScript
351 lines
9.7 KiB
TypeScript
/**
|
|
* API Connection Status Monitor and Health Checks
|
|
*/
|
|
|
|
import { EventEmitter } from 'events';
|
|
import { ApiError } from './ApiError';
|
|
import { CircuitBreakerRegistry } from './RetryHandler';
|
|
|
|
export type ConnectionStatus = 'connected' | 'disconnected' | 'degraded' | 'checking';
|
|
|
|
export interface ConnectionHealth {
|
|
status: ConnectionStatus;
|
|
lastCheck: Date | null;
|
|
lastSuccess: Date | null;
|
|
lastFailure: Date | null;
|
|
consecutiveFailures: number;
|
|
totalRequests: number;
|
|
successfulRequests: number;
|
|
failedRequests: number;
|
|
averageResponseTime: number;
|
|
}
|
|
|
|
export interface HealthCheckResult {
|
|
healthy: boolean;
|
|
responseTime: number;
|
|
error?: string;
|
|
timestamp: Date;
|
|
}
|
|
|
|
export class ApiConnectionMonitor extends EventEmitter {
|
|
private static instance: ApiConnectionMonitor;
|
|
private health: ConnectionHealth;
|
|
private isChecking = false;
|
|
private checkInterval: NodeJS.Timeout | null = null;
|
|
private healthCheckEndpoint: string;
|
|
private readonly CHECK_INTERVAL = 30000; // 30 seconds
|
|
private readonly DEGRADATION_THRESHOLD = 0.7; // 70% failure rate
|
|
|
|
private constructor(healthCheckEndpoint: string = '/health') {
|
|
super();
|
|
this.healthCheckEndpoint = healthCheckEndpoint;
|
|
this.health = {
|
|
status: 'disconnected',
|
|
lastCheck: null,
|
|
lastSuccess: null,
|
|
lastFailure: null,
|
|
consecutiveFailures: 0,
|
|
totalRequests: 0,
|
|
successfulRequests: 0,
|
|
failedRequests: 0,
|
|
averageResponseTime: 0,
|
|
};
|
|
}
|
|
|
|
static getInstance(healthCheckEndpoint?: string): ApiConnectionMonitor {
|
|
if (!ApiConnectionMonitor.instance) {
|
|
ApiConnectionMonitor.instance = new ApiConnectionMonitor(healthCheckEndpoint);
|
|
}
|
|
return ApiConnectionMonitor.instance;
|
|
}
|
|
|
|
/**
|
|
* Start automatic health monitoring
|
|
*/
|
|
startMonitoring(intervalMs?: number): void {
|
|
if (this.checkInterval) {
|
|
clearInterval(this.checkInterval);
|
|
}
|
|
|
|
const interval = intervalMs || this.CHECK_INTERVAL;
|
|
this.checkInterval = setInterval(() => {
|
|
this.performHealthCheck();
|
|
}, interval);
|
|
|
|
// Initial check
|
|
this.performHealthCheck();
|
|
}
|
|
|
|
/**
|
|
* Stop automatic health monitoring
|
|
*/
|
|
stopMonitoring(): void {
|
|
if (this.checkInterval) {
|
|
clearInterval(this.checkInterval);
|
|
this.checkInterval = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Perform a manual health check
|
|
*/
|
|
async performHealthCheck(): Promise<HealthCheckResult> {
|
|
if (this.isChecking) {
|
|
return {
|
|
healthy: false,
|
|
responseTime: 0,
|
|
error: 'Check already in progress',
|
|
timestamp: new Date(),
|
|
};
|
|
}
|
|
|
|
this.isChecking = true;
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
// Try multiple endpoints to determine actual connectivity
|
|
const baseUrl = this.getBaseUrl();
|
|
const endpointsToTry = [
|
|
`${baseUrl}${this.healthCheckEndpoint}`,
|
|
`${baseUrl}/api/health`,
|
|
`${baseUrl}/status`,
|
|
baseUrl, // Root endpoint
|
|
];
|
|
|
|
let lastError: Error | null = null;
|
|
let successfulResponse: Response | null = null;
|
|
|
|
for (const endpoint of endpointsToTry) {
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
|
|
|
const response = await fetch(endpoint, {
|
|
method: 'GET',
|
|
signal: controller.signal,
|
|
cache: 'no-store',
|
|
// Add credentials to handle auth
|
|
credentials: 'include',
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
// Consider any response (even 404) as connectivity success
|
|
if (response.ok || response.status === 404 || response.status === 401) {
|
|
successfulResponse = response;
|
|
break;
|
|
}
|
|
} catch (endpointError) {
|
|
lastError = endpointError as Error;
|
|
// Try next endpoint
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const responseTime = Date.now() - startTime;
|
|
|
|
if (successfulResponse) {
|
|
this.recordSuccess(responseTime);
|
|
this.isChecking = false;
|
|
|
|
return {
|
|
healthy: true,
|
|
responseTime,
|
|
timestamp: new Date(),
|
|
};
|
|
} else {
|
|
// If we got here, all endpoints failed
|
|
const errorMessage = lastError?.message || 'All endpoints failed to respond';
|
|
this.recordFailure(errorMessage);
|
|
this.isChecking = false;
|
|
|
|
return {
|
|
healthy: false,
|
|
responseTime,
|
|
error: errorMessage,
|
|
timestamp: new Date(),
|
|
};
|
|
}
|
|
} catch (error) {
|
|
const responseTime = Date.now() - startTime;
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
|
|
this.recordFailure(errorMessage);
|
|
this.isChecking = false;
|
|
|
|
return {
|
|
healthy: false,
|
|
responseTime,
|
|
error: errorMessage,
|
|
timestamp: new Date(),
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record a successful API request
|
|
*/
|
|
recordSuccess(responseTime: number = 0): void {
|
|
this.health.totalRequests++;
|
|
this.health.successfulRequests++;
|
|
this.health.consecutiveFailures = 0;
|
|
this.health.lastSuccess = new Date();
|
|
this.health.lastCheck = new Date();
|
|
|
|
// Update average response time
|
|
const total = this.health.successfulRequests;
|
|
this.health.averageResponseTime =
|
|
((this.health.averageResponseTime * (total - 1)) + responseTime) / total;
|
|
|
|
this.updateStatus();
|
|
this.emit('success', { responseTime });
|
|
}
|
|
|
|
/**
|
|
* Record a failed API request
|
|
*/
|
|
recordFailure(error: string | Error): void {
|
|
this.health.totalRequests++;
|
|
this.health.failedRequests++;
|
|
this.health.consecutiveFailures++;
|
|
this.health.lastFailure = new Date();
|
|
this.health.lastCheck = new Date();
|
|
|
|
this.updateStatus();
|
|
this.emit('failure', {
|
|
error: typeof error === 'string' ? error : error.message,
|
|
consecutiveFailures: this.health.consecutiveFailures
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get current connection health
|
|
*/
|
|
getHealth(): ConnectionHealth {
|
|
return { ...this.health };
|
|
}
|
|
|
|
/**
|
|
* Get current connection status
|
|
*/
|
|
getStatus(): ConnectionStatus {
|
|
return this.health.status;
|
|
}
|
|
|
|
/**
|
|
* Check if API is currently available
|
|
*/
|
|
isAvailable(): boolean {
|
|
return this.health.status === 'connected' || this.health.status === 'degraded';
|
|
}
|
|
|
|
/**
|
|
* Get reliability percentage
|
|
*/
|
|
getReliability(): number {
|
|
if (this.health.totalRequests === 0) return 0;
|
|
return (this.health.successfulRequests / this.health.totalRequests) * 100;
|
|
}
|
|
|
|
/**
|
|
* Reset all statistics
|
|
*/
|
|
reset(): void {
|
|
this.health = {
|
|
status: 'disconnected',
|
|
lastCheck: null,
|
|
lastSuccess: null,
|
|
lastFailure: null,
|
|
consecutiveFailures: 0,
|
|
totalRequests: 0,
|
|
successfulRequests: 0,
|
|
failedRequests: 0,
|
|
averageResponseTime: 0,
|
|
};
|
|
this.emit('reset');
|
|
}
|
|
|
|
/**
|
|
* Get detailed status report for development
|
|
*/
|
|
getDebugReport(): string {
|
|
const reliability = this.getReliability().toFixed(2);
|
|
const avgTime = this.health.averageResponseTime.toFixed(2);
|
|
|
|
return `API Connection Status:
|
|
Status: ${this.health.status}
|
|
Reliability: ${reliability}%
|
|
Total Requests: ${this.health.totalRequests}
|
|
Successful: ${this.health.successfulRequests}
|
|
Failed: ${this.health.failedRequests}
|
|
Consecutive Failures: ${this.health.consecutiveFailures}
|
|
Avg Response Time: ${avgTime}ms
|
|
Last Check: ${this.health.lastCheck?.toISOString() || 'never'}
|
|
Last Success: ${this.health.lastSuccess?.toISOString() || 'never'}
|
|
Last Failure: ${this.health.lastFailure?.toISOString() || 'never'}`;
|
|
}
|
|
|
|
private updateStatus(): void {
|
|
const reliability = this.health.totalRequests > 0
|
|
? this.health.successfulRequests / this.health.totalRequests
|
|
: 0;
|
|
|
|
// More nuanced status determination
|
|
if (this.health.totalRequests === 0) {
|
|
// No requests yet - don't assume disconnected
|
|
this.health.status = 'checking';
|
|
} else if (this.health.consecutiveFailures >= 3) {
|
|
// Multiple consecutive failures indicates real connectivity issue
|
|
this.health.status = 'disconnected';
|
|
} else if (reliability < this.DEGRADATION_THRESHOLD && this.health.totalRequests >= 5) {
|
|
// Only degrade if we have enough samples and reliability is low
|
|
this.health.status = 'degraded';
|
|
} else if (reliability >= this.DEGRADATION_THRESHOLD || this.health.successfulRequests > 0) {
|
|
// If we have any successes, we're connected
|
|
this.health.status = 'connected';
|
|
} else {
|
|
// Default to checking if uncertain
|
|
this.health.status = 'checking';
|
|
}
|
|
|
|
// Emit status change events (only on actual changes)
|
|
if (this.health.status === 'disconnected') {
|
|
this.emit('disconnected');
|
|
} else if (this.health.status === 'degraded') {
|
|
this.emit('degraded');
|
|
} else if (this.health.status === 'connected') {
|
|
this.emit('connected');
|
|
} else if (this.health.status === 'checking') {
|
|
this.emit('checking');
|
|
}
|
|
}
|
|
|
|
private getBaseUrl(): string {
|
|
// Try to get base URL from environment or fallback
|
|
if (typeof window !== 'undefined') {
|
|
return process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
|
}
|
|
return process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Global connection status utility
|
|
*/
|
|
export const connectionMonitor = ApiConnectionMonitor.getInstance();
|
|
|
|
/**
|
|
* Hook for React components to monitor connection status
|
|
*/
|
|
export function useConnectionStatus() {
|
|
const monitor = ApiConnectionMonitor.getInstance();
|
|
|
|
return {
|
|
status: monitor.getStatus(),
|
|
health: monitor.getHealth(),
|
|
isAvailable: monitor.isAvailable(),
|
|
reliability: monitor.getReliability(),
|
|
checkHealth: () => monitor.performHealthCheck(),
|
|
getDebugReport: () => monitor.getDebugReport(),
|
|
};
|
|
} |