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>
This commit is contained in:
parent
b08574efe5
commit
17e0f2f03d
173
CONNECTION_RETRY.md
Normal file
173
CONNECTION_RETRY.md
Normal file
@ -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<ResponseType>(
|
||||
'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
|
10
README.md
10
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.
|
||||
|
@ -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 = `<p class="text-warning">Too many requests. Please wait a moment.</p>`;
|
||||
statusIndicator.textContent = 'Rate limit - please wait';
|
||||
}
|
||||
else if (error.message?.includes('connection') || error.message?.includes('network')) {
|
||||
sourceText.innerHTML = `<p class="text-warning">Connection error. Your request will be processed when connection is restored.</p>`;
|
||||
statusIndicator.textContent = 'Connection error - queued';
|
||||
connectionUI.showTemporaryMessage('Request queued for when connection returns', 'warning');
|
||||
}
|
||||
else if (!navigator.onLine) {
|
||||
sourceText.innerHTML = `<p class="text-warning">You're offline. Request will be sent when connection is restored.</p>`;
|
||||
statusIndicator.textContent = 'Offline - request queued';
|
||||
}
|
||||
else {
|
||||
sourceText.innerHTML = `<p class="text-danger">Failed to transcribe audio. Please try again.</p>`;
|
||||
statusIndicator.textContent = 'Transcription failed';
|
||||
@ -783,6 +797,11 @@ function initApp() {
|
||||
translatedText.innerHTML = `<p class="text-warning">Too many requests. Please wait a moment.</p>`;
|
||||
statusIndicator.textContent = 'Rate limit - please wait';
|
||||
}
|
||||
else if (error.message?.includes('connection') || error.message?.includes('network')) {
|
||||
translatedText.innerHTML = `<p class="text-warning">Connection error. Your translation will be processed when connection is restored.</p>`;
|
||||
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 = `<p class="text-warning">You're offline. Only cached translations are available.</p>`;
|
||||
@ -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
|
||||
|
@ -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 = `<p class="text-warning">Too many requests. Please wait a moment.</p>`;
|
||||
statusIndicator.textContent = 'Rate limit - please wait';
|
||||
} else if (error.message?.includes('connection') || error.message?.includes('network')) {
|
||||
sourceText.innerHTML = `<p class="text-warning">Connection error. Your request will be processed when connection is restored.</p>`;
|
||||
statusIndicator.textContent = 'Connection error - queued';
|
||||
connectionUI.showTemporaryMessage('Request queued for when connection returns', 'warning');
|
||||
} else if (!navigator.onLine) {
|
||||
sourceText.innerHTML = `<p class="text-warning">You're offline. Request will be sent when connection is restored.</p>`;
|
||||
statusIndicator.textContent = 'Offline - request queued';
|
||||
} else {
|
||||
sourceText.innerHTML = `<p class="text-danger">Failed to transcribe audio. Please try again.</p>`;
|
||||
statusIndicator.textContent = 'Transcription failed';
|
||||
@ -962,6 +975,10 @@ function initApp(): void {
|
||||
if (error.message?.includes('Rate limit')) {
|
||||
translatedText.innerHTML = `<p class="text-warning">Too many requests. Please wait a moment.</p>`;
|
||||
statusIndicator.textContent = 'Rate limit - please wait';
|
||||
} else if (error.message?.includes('connection') || error.message?.includes('network')) {
|
||||
translatedText.innerHTML = `<p class="text-warning">Connection error. Your translation will be processed when connection is restored.</p>`;
|
||||
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 = `<p class="text-warning">You're offline. Only cached translations are available.</p>`;
|
||||
@ -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';
|
||||
|
||||
|
321
static/js/src/connectionManager.ts
Normal file
321
static/js/src/connectionManager.ts
Normal file
@ -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<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();
|
||||
}
|
325
static/js/src/connectionUI.ts
Normal file
325
static/js/src/connectionUI.ts
Normal file
@ -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 = `
|
||||
<span class="connection-icon"></span>
|
||||
<span class="connection-text">Checking connection...</span>
|
||||
`;
|
||||
|
||||
// Create offline message banner
|
||||
this.offlineMessage = document.createElement('div');
|
||||
this.offlineMessage.id = 'offlineMessage';
|
||||
this.offlineMessage.className = 'offline-message';
|
||||
this.offlineMessage.innerHTML = `
|
||||
<div class="offline-content">
|
||||
<i class="fas fa-wifi-slash"></i>
|
||||
<span class="offline-text">You're offline. Some features may be limited.</span>
|
||||
<button class="btn btn-sm btn-outline-light retry-connection">
|
||||
<i class="fas fa-sync"></i> Retry
|
||||
</button>
|
||||
<div class="queued-info" style="display: none;">
|
||||
<small class="queued-count"></small>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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 = '<i class="fas fa-spinner fa-spin"></i> 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 = '<i class="fas fa-sync"></i> 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<void> {
|
||||
if (this.retryButton) {
|
||||
this.retryButton.disabled = true;
|
||||
this.retryButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Connecting...';
|
||||
}
|
||||
|
||||
const success = await this.connectionManager.reconnect();
|
||||
|
||||
if (!success && this.retryButton) {
|
||||
this.retryButton.disabled = false;
|
||||
this.retryButton.innerHTML = '<i class="fas fa-sync"></i> 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);
|
||||
}
|
||||
}
|
@ -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<void> {
|
||||
// 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(() => {
|
||||
// Re-queue with higher priority
|
||||
request.priority = Math.max(request.priority + 1, 10);
|
||||
this.addToQueue(request);
|
||||
}, delay);
|
||||
} 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;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user