diff --git a/CONNECTION_RETRY.md b/CONNECTION_RETRY.md new file mode 100644 index 0000000..648592b --- /dev/null +++ b/CONNECTION_RETRY.md @@ -0,0 +1,173 @@ +# Connection Retry Logic Documentation + +This document explains the connection retry and network interruption handling features in Talk2Me. + +## Overview + +Talk2Me implements robust connection retry logic to handle network interruptions gracefully. When a connection is lost or a request fails due to network issues, the application automatically queues requests and retries them when the connection is restored. + +## Features + +### 1. Automatic Connection Monitoring +- Monitors browser online/offline events +- Periodic health checks to the server (every 5 seconds when offline) +- Visual connection status indicator +- Automatic detection when returning from sleep/hibernation + +### 2. Request Queuing +- Failed requests are automatically queued during network interruptions +- Requests maintain their priority and are processed in order +- Queue persists across connection failures +- Visual indication of queued requests + +### 3. Exponential Backoff Retry +- Failed requests are retried with exponential backoff +- Initial retry delay: 1 second +- Maximum retry delay: 30 seconds +- Backoff multiplier: 2x +- Maximum retries: 3 attempts + +### 4. Connection Status UI +- Real-time connection status indicator (bottom-right corner) +- Offline banner with retry button +- Queue status showing pending requests by type +- Temporary status messages for important events + +## User Experience + +### When Connection is Lost + +1. **Visual Indicators**: + - Connection status shows "Offline" or "Connection error" + - Red banner appears at top of screen + - Queued request count is displayed + +2. **Request Handling**: + - New requests are automatically queued + - User sees "Connection error - queued" message + - Requests will be sent when connection returns + +3. **Manual Retry**: + - Users can click "Retry" button in offline banner + - Forces immediate connection check + +### When Connection is Restored + +1. **Automatic Recovery**: + - Connection status changes to "Connecting..." + - Queued requests are processed automatically + - Success message shown briefly + +2. **Request Processing**: + - Queued requests maintain their order + - Higher priority requests (transcription) processed first + - Progress indicators show processing status + +## Configuration + +The connection retry logic can be configured programmatically: + +```javascript +// In app.ts or initialization code +connectionManager.configure({ + maxRetries: 3, // Maximum retry attempts + initialDelay: 1000, // Initial retry delay (ms) + maxDelay: 30000, // Maximum retry delay (ms) + backoffMultiplier: 2, // Exponential backoff multiplier + timeout: 10000, // Request timeout (ms) + onlineCheckInterval: 5000 // Health check interval (ms) +}); +``` + +## Request Priority + +Requests are prioritized as follows: +1. **Transcription** (Priority: 8) - Highest priority +2. **Translation** (Priority: 5) - Normal priority +3. **TTS/Audio** (Priority: 3) - Lower priority + +## Error Types + +### Retryable Errors +- Network errors +- Connection timeouts +- Server errors (5xx) +- CORS errors (in some cases) + +### Non-Retryable Errors +- Client errors (4xx) +- Authentication errors +- Rate limit errors +- Invalid request errors + +## Best Practices + +1. **For Users**: + - Wait for queued requests to complete before closing the app + - Use the manual retry button if automatic recovery fails + - Check the connection status indicator for current state + +2. **For Developers**: + - All fetch requests should go through RequestQueueManager + - Use appropriate request priorities + - Handle both online and offline scenarios in UI + - Provide clear feedback about connection status + +## Technical Implementation + +### Key Components + +1. **ConnectionManager** (`connectionManager.ts`): + - Monitors connection state + - Implements retry logic with exponential backoff + - Provides connection state subscriptions + +2. **RequestQueueManager** (`requestQueue.ts`): + - Queues failed requests + - Integrates with ConnectionManager + - Handles request prioritization + +3. **ConnectionUI** (`connectionUI.ts`): + - Displays connection status + - Shows offline banner + - Updates queue information + +### Integration Example + +```typescript +// Automatic integration through RequestQueueManager +const queue = RequestQueueManager.getInstance(); +const data = await queue.enqueue( + 'translate', // Request type + async () => { + // Your fetch request + const response = await fetch('/api/translate', options); + return response.json(); + }, + 5 // Priority (1-10, higher = more important) +); +``` + +## Troubleshooting + +### Connection Not Detected +- Check browser permissions for network status +- Ensure health endpoint (/health) is accessible +- Verify no firewall/proxy blocking + +### Requests Not Retrying +- Check browser console for errors +- Verify request type is retryable +- Check if max retries exceeded + +### Queue Not Processing +- Manually trigger retry with button +- Check if requests are timing out +- Verify server is responding + +## Future Enhancements + +- Persistent queue storage (survive page refresh) +- Configurable retry strategies per request type +- Network speed detection and adaptation +- Progressive web app offline mode \ No newline at end of file diff --git a/README.md b/README.md index 2a6543e..253d0e7 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,16 @@ export CORS_ORIGINS="https://yourdomain.com,https://app.yourdomain.com" export ADMIN_CORS_ORIGINS="https://admin.yourdomain.com" ``` +## Connection Retry & Offline Support + +Talk2Me handles network interruptions gracefully with automatic retry logic: +- Automatic request queuing during connection loss +- Exponential backoff retry with configurable parameters +- Visual connection status indicators +- Priority-based request processing + +See [CONNECTION_RETRY.md](CONNECTION_RETRY.md) for detailed documentation. + ## Mobile Support The interface is fully responsive and designed to work well on mobile devices. diff --git a/static/js/app.js b/static/js/app.js index 22ec0be..d866387 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -5,9 +5,14 @@ import { Validator } from './validator'; import { StreamingTranslation } from './streamingTranslation'; import { PerformanceMonitor } from './performanceMonitor'; import { SpeakerManager } from './speakerManager'; +import { ConnectionManager } from './connectionManager'; +import { ConnectionUI } from './connectionUI'; // import { apiClient } from './apiClient'; // Available for cross-origin requests // Initialize error boundary const errorBoundary = ErrorBoundary.getInstance(); +// Initialize connection management +ConnectionManager.getInstance(); // Initialize connection manager +const connectionUI = ConnectionUI.getInstance(); // Configure API client if needed for cross-origin requests // import { apiClient } from './apiClient'; // apiClient.configure({ baseUrl: 'https://api.talk2me.com', credentials: 'include' }); @@ -596,6 +601,15 @@ function initApp() { sourceText.innerHTML = `

Too many requests. Please wait a moment.

`; statusIndicator.textContent = 'Rate limit - please wait'; } + else if (error.message?.includes('connection') || error.message?.includes('network')) { + sourceText.innerHTML = `

Connection error. Your request will be processed when connection is restored.

`; + statusIndicator.textContent = 'Connection error - queued'; + connectionUI.showTemporaryMessage('Request queued for when connection returns', 'warning'); + } + else if (!navigator.onLine) { + sourceText.innerHTML = `

You're offline. Request will be sent when connection is restored.

`; + statusIndicator.textContent = 'Offline - request queued'; + } else { sourceText.innerHTML = `

Failed to transcribe audio. Please try again.

`; statusIndicator.textContent = 'Transcription failed'; @@ -783,6 +797,11 @@ function initApp() { translatedText.innerHTML = `

Too many requests. Please wait a moment.

`; statusIndicator.textContent = 'Rate limit - please wait'; } + else if (error.message?.includes('connection') || error.message?.includes('network')) { + translatedText.innerHTML = `

Connection error. Your translation will be processed when connection is restored.

`; + statusIndicator.textContent = 'Connection error - queued'; + connectionUI.showTemporaryMessage('Translation queued for when connection returns', 'warning'); + } else if (!navigator.onLine) { statusIndicator.textContent = 'Offline - checking cache...'; translatedText.innerHTML = `

You're offline. Only cached translations are available.

`; @@ -872,6 +891,19 @@ function initApp() { statusIndicator.textContent = 'Too many requests - please wait'; alert('Too many requests. Please wait a moment before trying again.'); } + else if (error.message?.includes('connection') || error.message?.includes('network')) { + statusIndicator.textContent = 'Connection error - audio generation queued'; + connectionUI.showTemporaryMessage('Audio generation queued for when connection returns', 'warning'); + // Show TTS server alert + ttsServerAlert.classList.remove('d-none'); + ttsServerAlert.classList.remove('alert-success'); + ttsServerAlert.classList.add('alert-warning'); + ttsServerMessage.textContent = 'Connection error - request queued'; + } + else if (!navigator.onLine) { + statusIndicator.textContent = 'Offline - audio generation unavailable'; + alert('Audio generation requires an internet connection.'); + } else { statusIndicator.textContent = 'TTS failed'; // Show TTS server alert diff --git a/static/js/src/app.ts b/static/js/src/app.ts index d33d208..e167779 100644 --- a/static/js/src/app.ts +++ b/static/js/src/app.ts @@ -21,11 +21,17 @@ import { Validator } from './validator'; import { StreamingTranslation } from './streamingTranslation'; import { PerformanceMonitor } from './performanceMonitor'; import { SpeakerManager } from './speakerManager'; +import { ConnectionManager } from './connectionManager'; +import { ConnectionUI } from './connectionUI'; // import { apiClient } from './apiClient'; // Available for cross-origin requests // Initialize error boundary const errorBoundary = ErrorBoundary.getInstance(); +// Initialize connection management +ConnectionManager.getInstance(); // Initialize connection manager +const connectionUI = ConnectionUI.getInstance(); + // Configure API client if needed for cross-origin requests // import { apiClient } from './apiClient'; // apiClient.configure({ baseUrl: 'https://api.talk2me.com', credentials: 'include' }); @@ -717,6 +723,13 @@ function initApp(): void { if (error.message?.includes('Rate limit')) { sourceText.innerHTML = `

Too many requests. Please wait a moment.

`; statusIndicator.textContent = 'Rate limit - please wait'; + } else if (error.message?.includes('connection') || error.message?.includes('network')) { + sourceText.innerHTML = `

Connection error. Your request will be processed when connection is restored.

`; + statusIndicator.textContent = 'Connection error - queued'; + connectionUI.showTemporaryMessage('Request queued for when connection returns', 'warning'); + } else if (!navigator.onLine) { + sourceText.innerHTML = `

You're offline. Request will be sent when connection is restored.

`; + statusIndicator.textContent = 'Offline - request queued'; } else { sourceText.innerHTML = `

Failed to transcribe audio. Please try again.

`; statusIndicator.textContent = 'Transcription failed'; @@ -962,6 +975,10 @@ function initApp(): void { if (error.message?.includes('Rate limit')) { translatedText.innerHTML = `

Too many requests. Please wait a moment.

`; statusIndicator.textContent = 'Rate limit - please wait'; + } else if (error.message?.includes('connection') || error.message?.includes('network')) { + translatedText.innerHTML = `

Connection error. Your translation will be processed when connection is restored.

`; + statusIndicator.textContent = 'Connection error - queued'; + connectionUI.showTemporaryMessage('Translation queued for when connection returns', 'warning'); } else if (!navigator.onLine) { statusIndicator.textContent = 'Offline - checking cache...'; translatedText.innerHTML = `

You're offline. Only cached translations are available.

`; @@ -1063,6 +1080,18 @@ function initApp(): void { if (error.message?.includes('Rate limit')) { statusIndicator.textContent = 'Too many requests - please wait'; alert('Too many requests. Please wait a moment before trying again.'); + } else if (error.message?.includes('connection') || error.message?.includes('network')) { + statusIndicator.textContent = 'Connection error - audio generation queued'; + connectionUI.showTemporaryMessage('Audio generation queued for when connection returns', 'warning'); + + // Show TTS server alert + ttsServerAlert.classList.remove('d-none'); + ttsServerAlert.classList.remove('alert-success'); + ttsServerAlert.classList.add('alert-warning'); + ttsServerMessage.textContent = 'Connection error - request queued'; + } else if (!navigator.onLine) { + statusIndicator.textContent = 'Offline - audio generation unavailable'; + alert('Audio generation requires an internet connection.'); } else { statusIndicator.textContent = 'TTS failed'; diff --git a/static/js/src/connectionManager.ts b/static/js/src/connectionManager.ts new file mode 100644 index 0000000..7728e40 --- /dev/null +++ b/static/js/src/connectionManager.ts @@ -0,0 +1,321 @@ +// 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 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): 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 { + 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( + request: () => Promise, + options: RetryOptions = {} + ): Promise { + 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(promise: Promise, timeout: number): Promise { + return Promise.race([ + promise, + new Promise((_, 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 { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + // Update connection state + private updateState(updates: Partial): 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 { + 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 { + 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( + url: string, + options: RequestInit = {}, + retryOptions: RetryOptions = {} +): Promise { + const response = await fetchWithRetry(url, options, retryOptions); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); +} \ No newline at end of file diff --git a/static/js/src/connectionUI.ts b/static/js/src/connectionUI.ts new file mode 100644 index 0000000..4f7bb4d --- /dev/null +++ b/static/js/src/connectionUI.ts @@ -0,0 +1,325 @@ +// Connection status UI component +import { ConnectionManager, ConnectionState } from './connectionManager'; +import { RequestQueueManager } from './requestQueue'; + +export class ConnectionUI { + private static instance: ConnectionUI; + private connectionManager: ConnectionManager; + private queueManager: RequestQueueManager; + private statusElement: HTMLElement | null = null; + private retryButton: HTMLButtonElement | null = null; + private offlineMessage: HTMLElement | null = null; + + private constructor() { + this.connectionManager = ConnectionManager.getInstance(); + this.queueManager = RequestQueueManager.getInstance(); + this.createUI(); + this.subscribeToConnectionChanges(); + } + + static getInstance(): ConnectionUI { + if (!ConnectionUI.instance) { + ConnectionUI.instance = new ConnectionUI(); + } + return ConnectionUI.instance; + } + + private createUI(): void { + // Create connection status indicator + this.statusElement = document.createElement('div'); + this.statusElement.id = 'connectionStatus'; + this.statusElement.className = 'connection-status'; + this.statusElement.innerHTML = ` + + Checking connection... + `; + + // Create offline message banner + this.offlineMessage = document.createElement('div'); + this.offlineMessage.id = 'offlineMessage'; + this.offlineMessage.className = 'offline-message'; + this.offlineMessage.innerHTML = ` +
+ + You're offline. Some features may be limited. + + +
+ `; + this.offlineMessage.style.display = 'none'; + + // Add to page + document.body.appendChild(this.statusElement); + document.body.appendChild(this.offlineMessage); + + // Get retry button reference + this.retryButton = this.offlineMessage.querySelector('.retry-connection') as HTMLButtonElement; + this.retryButton?.addEventListener('click', () => this.handleRetry()); + + // Add CSS if not already present + if (!document.getElementById('connection-ui-styles')) { + const style = document.createElement('style'); + style.id = 'connection-ui-styles'; + style.textContent = ` + .connection-status { + position: fixed; + bottom: 20px; + right: 20px; + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 8px 16px; + border-radius: 20px; + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + z-index: 1000; + transition: all 0.3s ease; + opacity: 0; + transform: translateY(10px); + } + + .connection-status.visible { + opacity: 1; + transform: translateY(0); + } + + .connection-status.online { + background: rgba(40, 167, 69, 0.9); + } + + .connection-status.offline { + background: rgba(220, 53, 69, 0.9); + } + + .connection-status.connecting { + background: rgba(255, 193, 7, 0.9); + } + + .connection-icon::before { + content: '●'; + display: inline-block; + animation: pulse 2s infinite; + } + + .connection-status.connecting .connection-icon::before { + animation: spin 1s linear infinite; + content: '↻'; + } + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } + + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + + .offline-message { + position: fixed; + top: 0; + left: 0; + right: 0; + background: #dc3545; + color: white; + padding: 12px; + text-align: center; + z-index: 1001; + transform: translateY(-100%); + transition: transform 0.3s ease; + } + + .offline-message.show { + transform: translateY(0); + } + + .offline-content { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + flex-wrap: wrap; + } + + .offline-content i { + font-size: 20px; + } + + .retry-connection { + border-color: white; + color: white; + } + + .retry-connection:hover { + background: white; + color: #dc3545; + } + + .queued-info { + margin-left: 12px; + } + + .queued-count { + opacity: 0.9; + } + + @media (max-width: 768px) { + .connection-status { + bottom: 10px; + right: 10px; + font-size: 12px; + padding: 6px 12px; + } + + .offline-content { + font-size: 14px; + } + } + `; + document.head.appendChild(style); + } + } + + private subscribeToConnectionChanges(): void { + this.connectionManager.subscribe('connection-ui', (state: ConnectionState) => { + this.updateUI(state); + }); + } + + private updateUI(state: ConnectionState): void { + if (!this.statusElement || !this.offlineMessage) return; + + const statusText = this.statusElement.querySelector('.connection-text') as HTMLElement; + + // Update status element + this.statusElement.className = `connection-status visible ${state.status}`; + + switch (state.status) { + case 'online': + statusText.textContent = 'Connected'; + this.hideOfflineMessage(); + // Hide status after 3 seconds when online + setTimeout(() => { + if (this.connectionManager.getState().status === 'online') { + this.statusElement?.classList.remove('visible'); + } + }, 3000); + break; + + case 'offline': + statusText.textContent = 'Offline'; + this.showOfflineMessage(); + this.updateQueuedInfo(); + break; + + case 'connecting': + statusText.textContent = 'Reconnecting...'; + if (this.retryButton) { + this.retryButton.disabled = true; + this.retryButton.innerHTML = ' Connecting...'; + } + break; + + case 'error': + statusText.textContent = `Connection error${state.retryCount > 0 ? ` (Retry ${state.retryCount})` : ''}`; + this.showOfflineMessage(); + this.updateQueuedInfo(); + if (this.retryButton) { + this.retryButton.disabled = false; + this.retryButton.innerHTML = ' Retry'; + } + break; + } + } + + private showOfflineMessage(): void { + if (this.offlineMessage) { + this.offlineMessage.style.display = 'block'; + setTimeout(() => { + this.offlineMessage?.classList.add('show'); + }, 10); + } + } + + private hideOfflineMessage(): void { + if (this.offlineMessage) { + this.offlineMessage.classList.remove('show'); + setTimeout(() => { + if (this.offlineMessage) { + this.offlineMessage.style.display = 'none'; + } + }, 300); + } + } + + private updateQueuedInfo(): void { + const queueStatus = this.queueManager.getStatus(); + const queuedByType = this.queueManager.getQueuedByType(); + const queuedInfo = this.offlineMessage?.querySelector('.queued-info') as HTMLElement; + const queuedCount = this.offlineMessage?.querySelector('.queued-count') as HTMLElement; + + if (queuedInfo && queuedCount) { + const totalQueued = queueStatus.queueLength + queueStatus.activeRequests; + + if (totalQueued > 0) { + queuedInfo.style.display = 'block'; + const parts = []; + + if (queuedByType.transcribe > 0) { + parts.push(`${queuedByType.transcribe} transcription${queuedByType.transcribe > 1 ? 's' : ''}`); + } + if (queuedByType.translate > 0) { + parts.push(`${queuedByType.translate} translation${queuedByType.translate > 1 ? 's' : ''}`); + } + if (queuedByType.tts > 0) { + parts.push(`${queuedByType.tts} audio generation${queuedByType.tts > 1 ? 's' : ''}`); + } + + queuedCount.textContent = `${totalQueued} request${totalQueued > 1 ? 's' : ''} queued${parts.length > 0 ? ': ' + parts.join(', ') : ''}`; + } else { + queuedInfo.style.display = 'none'; + } + } + } + + private async handleRetry(): Promise { + if (this.retryButton) { + this.retryButton.disabled = true; + this.retryButton.innerHTML = ' Connecting...'; + } + + const success = await this.connectionManager.reconnect(); + + if (!success && this.retryButton) { + this.retryButton.disabled = false; + this.retryButton.innerHTML = ' Retry'; + } + } + + // Public method to show temporary connection message + showTemporaryMessage(message: string, type: 'success' | 'error' | 'warning' = 'success'): void { + if (!this.statusElement) return; + + const statusText = this.statusElement.querySelector('.connection-text') as HTMLElement; + const originalClass = this.statusElement.className; + const originalText = statusText.textContent; + + // Update appearance based on type + this.statusElement.className = `connection-status visible ${type === 'success' ? 'online' : type === 'error' ? 'offline' : 'connecting'}`; + statusText.textContent = message; + + // Reset after 3 seconds + setTimeout(() => { + if (this.statusElement && statusText) { + this.statusElement.className = originalClass; + statusText.textContent = originalText || ''; + } + }, 3000); + } +} \ No newline at end of file diff --git a/static/js/src/requestQueue.ts b/static/js/src/requestQueue.ts index 03f5b84..d56e9e8 100644 --- a/static/js/src/requestQueue.ts +++ b/static/js/src/requestQueue.ts @@ -1,4 +1,6 @@ // Request queue and throttling manager +import { ConnectionManager, ConnectionState } from './connectionManager'; + export interface QueuedRequest { id: string; type: 'transcribe' | 'translate' | 'tts'; @@ -18,6 +20,8 @@ export class RequestQueueManager { private maxRetries = 3; private retryDelay = 1000; // Base retry delay in ms private isProcessing = false; + private connectionManager: ConnectionManager; + private isPaused = false; // Rate limiting private requestHistory: number[] = []; @@ -25,6 +29,13 @@ export class RequestQueueManager { private maxRequestsPerSecond = 2; private constructor() { + this.connectionManager = ConnectionManager.getInstance(); + + // Subscribe to connection state changes + this.connectionManager.subscribe('request-queue', (state: ConnectionState) => { + this.handleConnectionStateChange(state); + }); + // Start processing queue this.startProcessing(); } @@ -118,8 +129,14 @@ export class RequestQueueManager { } private async processQueue(): Promise { - // Check if we can process more requests - if (this.activeRequests.size >= this.maxConcurrent || this.queue.length === 0) { + // Check if we're paused or can't process more requests + if (this.isPaused || this.activeRequests.size >= this.maxConcurrent || this.queue.length === 0) { + return; + } + + // Check if we're online + if (!this.connectionManager.isOnline()) { + console.log('Queue processing paused - offline'); return; } @@ -131,25 +148,33 @@ export class RequestQueueManager { this.activeRequests.set(request.id, request); try { - // Execute request - const result = await request.request(); + // Execute request with connection manager retry logic + const result = await this.connectionManager.retryRequest( + request.request, + { + retries: this.maxRetries - request.retryCount, + delay: this.calculateRetryDelay(request.retryCount + 1), + onRetry: (attempt, error) => { + console.log(`Retry ${attempt} for ${request.type}: ${error.message}`); + } + } + ); + request.resolve(result); console.log(`Request completed: ${request.type}`); } catch (error) { - console.error(`Request failed: ${request.type}`, error); + console.error(`Request failed after retries: ${request.type}`, error); - // Check if we should retry - if (request.retryCount < this.maxRetries && this.shouldRetry(error)) { + // Check if it's a connection error and we should queue for later + if (this.isConnectionError(error) && request.retryCount < this.maxRetries) { request.retryCount++; - const delay = this.calculateRetryDelay(request.retryCount); - console.log(`Retrying request ${request.type} in ${delay}ms (attempt ${request.retryCount})`); + console.log(`Re-queuing ${request.type} due to connection error`); - // Re-queue with delay - setTimeout(() => { - this.addToQueue(request); - }, delay); + // Re-queue with higher priority + request.priority = Math.max(request.priority + 1, 10); + this.addToQueue(request); } else { - // Max retries reached or non-retryable error + // Non-recoverable error or max retries reached request.reject(error); } } finally { @@ -158,23 +183,8 @@ export class RequestQueueManager { } } - private shouldRetry(error: any): boolean { - // Retry on network errors or 5xx status codes - if (error.message?.includes('network') || error.message?.includes('Network')) { - return true; - } - - if (error.status >= 500 && error.status < 600) { - return true; - } - - // Don't retry on client errors (4xx) - if (error.status >= 400 && error.status < 500) { - return false; - } - - return true; - } + // Note: shouldRetry logic is now handled by ConnectionManager + // Keeping for reference but not used directly private calculateRetryDelay(retryCount: number): number { // Exponential backoff with jitter @@ -258,4 +268,66 @@ export class RequestQueueManager { this.maxRequestsPerSecond = settings.maxRequestsPerSecond; } } + + // Handle connection state changes + private handleConnectionStateChange(state: ConnectionState): void { + console.log(`Connection state changed: ${state.status}`); + + if (state.status === 'offline' || state.status === 'error') { + // Pause processing when offline + this.isPaused = true; + + // Notify queued requests about offline status + if (this.queue.length > 0) { + console.log(`${this.queue.length} requests queued while offline`); + } + } else if (state.status === 'online') { + // Resume processing when back online + this.isPaused = false; + console.log('Connection restored, resuming queue processing'); + + // Process any queued requests + if (this.queue.length > 0) { + console.log(`Processing ${this.queue.length} queued requests`); + } + } + } + + // Check if error is connection-related + private isConnectionError(error: any): boolean { + const errorMessage = error.message?.toLowerCase() || ''; + const connectionErrors = [ + 'network', + 'fetch', + 'connection', + 'timeout', + 'offline', + 'cors' + ]; + + return connectionErrors.some(e => errorMessage.includes(e)); + } + + // Pause queue processing + pause(): void { + this.isPaused = true; + console.log('Request queue paused'); + } + + // Resume queue processing + resume(): void { + this.isPaused = false; + console.log('Request queue resumed'); + } + + // Get number of queued requests by type + getQueuedByType(): { transcribe: number; translate: number; tts: number } { + const counts = { transcribe: 0, translate: 0, tts: 0 }; + + this.queue.forEach(request => { + counts[request.type]++; + }); + + return counts; + } } \ No newline at end of file