talk2me/static/js/src/connectionManager.ts
Adolfo Delorenzo 17e0f2f03d Add connection retry logic to handle network interruptions gracefully
- Implement ConnectionManager with exponential backoff retry strategy
- Add automatic connection monitoring and health checks
- Update RequestQueueManager to integrate with connection state
- Create ConnectionUI component for visual connection status
- Queue requests during offline periods and process when online
- Add comprehensive error handling for network-related failures
- Create detailed documentation for connection retry features
- Support manual retry and automatic recovery

Features:
- Real-time connection status indicator
- Offline banner with retry button
- Request queue visualization
- Priority-based request processing
- Configurable retry parameters

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-03 00:00:03 -06:00

321 lines
10 KiB
TypeScript

// Connection management with retry logic
export interface ConnectionConfig {
maxRetries: number;
initialDelay: number;
maxDelay: number;
backoffMultiplier: number;
timeout: number;
onlineCheckInterval: number;
}
export interface RetryOptions {
retries?: number;
delay?: number;
onRetry?: (attempt: number, error: Error) => void;
}
export type ConnectionStatus = 'online' | 'offline' | 'connecting' | 'error';
export interface ConnectionState {
status: ConnectionStatus;
lastError?: Error;
retryCount: number;
lastOnlineTime?: Date;
}
export class ConnectionManager {
private static instance: ConnectionManager;
private config: ConnectionConfig;
private state: ConnectionState;
private listeners: Map<string, (state: ConnectionState) => void> = new Map();
private onlineCheckTimer?: number;
private reconnectTimer?: number;
private constructor() {
this.config = {
maxRetries: 3,
initialDelay: 1000, // 1 second
maxDelay: 30000, // 30 seconds
backoffMultiplier: 2,
timeout: 10000, // 10 seconds
onlineCheckInterval: 5000 // 5 seconds
};
this.state = {
status: navigator.onLine ? 'online' : 'offline',
retryCount: 0
};
this.setupEventListeners();
this.startOnlineCheck();
}
static getInstance(): ConnectionManager {
if (!ConnectionManager.instance) {
ConnectionManager.instance = new ConnectionManager();
}
return ConnectionManager.instance;
}
// Configure connection settings
configure(config: Partial<ConnectionConfig>): void {
this.config = { ...this.config, ...config };
}
// Setup browser online/offline event listeners
private setupEventListeners(): void {
window.addEventListener('online', () => {
console.log('Browser online event detected');
this.updateState({ status: 'online', retryCount: 0 });
this.checkServerConnection();
});
window.addEventListener('offline', () => {
console.log('Browser offline event detected');
this.updateState({ status: 'offline' });
});
// Listen for visibility changes to check connection when tab becomes active
document.addEventListener('visibilitychange', () => {
if (!document.hidden && this.state.status === 'offline') {
this.checkServerConnection();
}
});
}
// Start periodic online checking
private startOnlineCheck(): void {
this.onlineCheckTimer = window.setInterval(() => {
if (this.state.status === 'offline' || this.state.status === 'error') {
this.checkServerConnection();
}
}, this.config.onlineCheckInterval);
}
// Check actual server connection
async checkServerConnection(): Promise<boolean> {
if (!navigator.onLine) {
this.updateState({ status: 'offline' });
return false;
}
this.updateState({ status: 'connecting' });
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch('/health', {
method: 'GET',
signal: controller.signal,
cache: 'no-cache'
});
clearTimeout(timeoutId);
if (response.ok) {
this.updateState({
status: 'online',
retryCount: 0,
lastOnlineTime: new Date()
});
return true;
} else {
throw new Error(`Server returned status ${response.status}`);
}
} catch (error) {
this.updateState({
status: 'error',
lastError: error as Error
});
return false;
}
}
// Retry a failed request with exponential backoff
async retryRequest<T>(
request: () => Promise<T>,
options: RetryOptions = {}
): Promise<T> {
const {
retries = this.config.maxRetries,
delay = this.config.initialDelay,
onRetry
} = options;
let lastError: Error;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
// Check if we're online before attempting
if (!navigator.onLine) {
throw new Error('No internet connection');
}
// Add timeout to request
const result = await this.withTimeout(request(), this.config.timeout);
// Success - reset retry count
if (this.state.retryCount > 0) {
this.updateState({ retryCount: 0 });
}
return result;
} catch (error) {
lastError = error as Error;
// Don't retry if offline
if (!navigator.onLine) {
this.updateState({ status: 'offline' });
throw new Error('Request failed: No internet connection');
}
// Don't retry on client errors (4xx)
if (this.isClientError(error)) {
throw error;
}
// Call retry callback if provided
if (onRetry && attempt < retries) {
onRetry(attempt + 1, lastError);
}
// If we have retries left, wait and try again
if (attempt < retries) {
const backoffDelay = Math.min(
delay * Math.pow(this.config.backoffMultiplier, attempt),
this.config.maxDelay
);
console.log(`Retry attempt ${attempt + 1}/${retries} after ${backoffDelay}ms`);
// Update retry count in state
this.updateState({ retryCount: attempt + 1 });
await this.delay(backoffDelay);
}
}
}
// All retries exhausted
this.updateState({
status: 'error',
lastError: lastError!
});
throw new Error(`Request failed after ${retries} retries: ${lastError!.message}`);
}
// Add timeout to a promise
private withTimeout<T>(promise: Promise<T>, timeout: number): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, reject) => {
setTimeout(() => reject(new Error('Request timeout')), timeout);
})
]);
}
// Check if error is a client error (4xx)
private isClientError(error: any): boolean {
if (error.response && error.response.status >= 400 && error.response.status < 500) {
return true;
}
// Check for specific error messages that shouldn't be retried
const message = error.message?.toLowerCase() || '';
const noRetryErrors = ['unauthorized', 'forbidden', 'bad request', 'not found'];
return noRetryErrors.some(e => message.includes(e));
}
// Delay helper
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Update connection state
private updateState(updates: Partial<ConnectionState>): void {
this.state = { ...this.state, ...updates };
this.notifyListeners();
}
// Subscribe to connection state changes
subscribe(id: string, callback: (state: ConnectionState) => void): void {
this.listeners.set(id, callback);
// Immediately call with current state
callback(this.state);
}
// Unsubscribe from connection state changes
unsubscribe(id: string): void {
this.listeners.delete(id);
}
// Notify all listeners of state change
private notifyListeners(): void {
this.listeners.forEach(callback => callback(this.state));
}
// Get current connection state
getState(): ConnectionState {
return { ...this.state };
}
// Check if currently online
isOnline(): boolean {
return this.state.status === 'online';
}
// Manual reconnect attempt
async reconnect(): Promise<boolean> {
console.log('Manual reconnect requested');
return this.checkServerConnection();
}
// Cleanup
destroy(): void {
if (this.onlineCheckTimer) {
clearInterval(this.onlineCheckTimer);
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
this.listeners.clear();
}
}
// Helper function for retrying fetch requests
export async function fetchWithRetry(
url: string,
options: RequestInit = {},
retryOptions: RetryOptions = {}
): Promise<Response> {
const connectionManager = ConnectionManager.getInstance();
return connectionManager.retryRequest(async () => {
const response = await fetch(url, options);
if (!response.ok && response.status >= 500) {
// Server error - throw to trigger retry
throw new Error(`Server error: ${response.status}`);
}
return response;
}, retryOptions);
}
// Helper function for retrying JSON requests
export async function fetchJSONWithRetry<T>(
url: string,
options: RequestInit = {},
retryOptions: RetryOptions = {}
): Promise<T> {
const response = await fetchWithRetry(url, options, retryOptions);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}