Files
gridpilot.gg/apps/website/lib/api/base/ApiConnectionMonitor.ts
2025-12-31 21:24:42 +01:00

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(),
};
}