Files
TimeTracker/app/static/error-handling-enhanced.js
T
Dries Peeters 3654a6a5d3 feat(offline): store method, headers, and body in queue for correct POST/PUT replay
- queueForOffline now saves url, method, headers, body (replay-safe for localStorage);
  legacy items with options only still replayed via fallback
- processOfflineQueue builds fetch options from stored method/body so replayed
  requests send the same payload when back online
- Make queueForOffline async and await it in handleFetchResponse/handleFetchException
- Add tests asserting queue stores method/body and replay uses them
2026-03-16 16:44:09 +01:00

953 lines
32 KiB
JavaScript

/**
* Enhanced Error Handling System
* User-friendly messages, retry buttons, offline queue, graceful degradation, and recovery options
*/
class EnhancedErrorHandler {
constructor() {
this.retryQueue = [];
this.offlineQueue = [];
this.isOnline = navigator.onLine;
this.retryAttempts = new Map();
this.maxRetries = 3;
// Track recent errors to prevent duplicates
this.recentErrors = new Map(); // message -> timestamp
this.errorDeduplicationWindow = 60000; // 1 minute - don't show same error twice within this window
this.init();
}
init() {
// Setup feature fallbacks first (before other initialization)
this.setupFeatureFallbacks();
// Setup network status monitoring
this.setupNetworkMonitoring();
// Setup fetch interceptors
this.setupFetchInterceptor();
// Setup global error handlers
this.setupGlobalErrorHandlers();
// Setup offline queue processor
this.setupOfflineQueue();
// Setup graceful degradation
this.setupGracefulDegradation();
}
/**
* Network Status Monitoring
*/
setupNetworkMonitoring() {
window.addEventListener('online', () => {
this.isOnline = true;
this.handleOnline();
});
window.addEventListener('offline', () => {
this.isOnline = false;
this.handleOffline();
});
// Periodic online check - every 30 seconds
// This helps detect cases where browser thinks it's online but server is unreachable
// The browser's online/offline events handle most cases, this is a fallback
setInterval(() => {
this.checkOnlineStatus();
}, 30000); // Check every 30 seconds instead of 5
}
checkOnlineStatus() {
fetch('/api/health', { method: 'GET', cache: 'no-cache' })
.then((response) => {
if (response.ok && !this.isOnline) {
this.isOnline = true;
this.handleOnline();
}
})
.catch(() => {
if (this.isOnline) {
this.isOnline = false;
this.handleOffline();
}
});
}
handleOnline() {
// Show online indicator
this.showOnlineIndicator();
// Process offline queue
this.processOfflineQueue();
// Retry failed operations
this.retryFailedOperations();
}
handleOffline() {
// Show offline indicator
this.showOfflineIndicator();
}
showOnlineIndicator() {
if (window.toastManager) {
window.toastManager.success(
'Connection restored. Processing queued operations...',
'Back Online',
3000
);
}
}
showOfflineIndicator() {
// Create persistent offline indicator
if (document.getElementById('offline-indicator')) return;
const indicator = document.createElement('div');
indicator.id = 'offline-indicator';
indicator.className = 'offline-indicator';
indicator.innerHTML = `
<div class="offline-indicator-content">
<i class="fas fa-wifi-slash"></i>
<span>You're offline. Some features may be limited.</span>
<span class="offline-queue-count" id="offline-queue-count"></span>
</div>
`;
document.body.appendChild(indicator);
// Add styles
this.addOfflineIndicatorStyles();
}
addOfflineIndicatorStyles() {
if (document.getElementById('offline-indicator-styles')) return;
const style = document.createElement('style');
style.id = 'offline-indicator-styles';
style.textContent = `
.offline-indicator {
position: fixed;
top: 0;
left: 0;
right: 0;
background: #f59e0b;
color: white;
padding: 12px 16px;
text-align: center;
z-index: 9999;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
animation: slideDown 0.3s ease-out;
}
.dark .offline-indicator {
background: #d97706;
}
.offline-indicator-content {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 0.875rem;
}
.offline-queue-count {
margin-left: 8px;
font-weight: 600;
}
@keyframes slideDown {
from {
transform: translateY(-100%);
}
to {
transform: translateY(0);
}
}
.error-retry-container {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.2);
display: flex;
flex-direction: column;
gap: 8px;
}
.error-retry-btn {
padding: 8px 16px;
background: rgba(255, 255, 255, 0.2);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s;
align-self: flex-start;
}
.error-retry-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.error-retry-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error-message-friendly {
margin-bottom: 8px;
}
.error-recovery-options {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px;
}
.error-recovery-btn {
padding: 6px 12px;
background: rgba(255, 255, 255, 0.15);
color: white;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.error-recovery-btn:hover {
background: rgba(255, 255, 255, 0.25);
}
`;
document.head.appendChild(style);
}
/**
* Fetch Interceptor for Error Handling
*/
setupFetchInterceptor() {
const originalFetch = window.fetch;
window.fetch = async (...args) => {
const [url, options = {}] = args;
try {
const response = await originalFetch(...args);
// Handle non-ok responses
if (!response.ok) {
return this.handleFetchError(response, url, options);
}
return response;
} catch (error) {
// Network error or other fetch error
return this.handleFetchException(error, url, options);
}
};
}
async handleFetchError(response, url, options) {
// Clone the response before reading it, so the caller can still read it
const clonedResponse = response.clone();
const errorData = await clonedResponse.json().catch(() => ({ error: 'Unknown error' }));
const userFriendlyMessage = this.getUserFriendlyMessage(response.status, errorData);
// Show error notification with retry option
const errorId = this.showErrorWithRetry(userFriendlyMessage, response.status, () => {
return this.retryFetch(url, options);
});
// Queue for offline processing if offline
if (!this.isOnline) {
await this.queueForOffline(url, options, errorId);
}
// Return original response so caller can handle it
return response;
}
async handleFetchException(error, url, options) {
// Network error
if (!this.isOnline) {
await this.queueForOffline(url, options);
return new Response(JSON.stringify({ error: 'Offline' }), {
status: 0,
statusText: 'Offline'
});
}
const userFriendlyMessage = this.getUserFriendlyMessage(0, error);
const errorId = this.showErrorWithRetry(userFriendlyMessage, 0, () => {
return this.retryFetch(url, options);
});
return new Response(JSON.stringify({ error: userFriendlyMessage }), {
status: 0,
statusText: 'Network Error'
});
}
async retryFetch(url, options) {
const retryKey = `${url}-${JSON.stringify(options)}`;
const attempts = this.retryAttempts.get(retryKey) || 0;
if (attempts >= this.maxRetries) {
this.showError(
'Maximum retry attempts reached. Please try again later or contact support.',
'Retry Failed'
);
return null;
}
this.retryAttempts.set(retryKey, attempts + 1);
try {
const response = await fetch(url, options);
if (response.ok) {
this.retryAttempts.delete(retryKey);
return response;
}
throw new Error(`HTTP ${response.status}`);
} catch (error) {
if (attempts < this.maxRetries - 1) {
// Wait before retrying (exponential backoff)
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempts) * 1000));
return this.retryFetch(url, options);
}
throw error;
}
}
/**
* User-Friendly Error Messages
*/
getUserFriendlyMessage(status, errorData) {
const errorMessage = errorData?.error || errorData?.message || 'An error occurred';
const messages = {
0: 'Unable to connect to the server. Please check your internet connection.',
400: 'Invalid request. Please check your input and try again.',
401: 'You need to log in to access this feature.',
403: 'You don\'t have permission to perform this action.',
404: 'The requested resource was not found.',
409: 'This action conflicts with existing data. Please refresh and try again.',
422: 'Validation error. Please check your input.',
429: 'Too many requests. Please wait a moment and try again.',
500: 'A server error occurred. Our team has been notified.',
502: 'The server is temporarily unavailable. Please try again later.',
503: 'Service temporarily unavailable. Please try again in a few moments.',
504: 'Request timeout. Please try again.'
};
// Try to get specific message from server
if (typeof errorData === 'object' && errorData.message) {
return errorData.message;
}
// Fallback to status-based message
return messages[status] || `An error occurred (${status}). ${errorMessage}`;
}
/**
* Show Error with Retry Button
*/
showErrorWithRetry(message, status, retryCallback) {
const recoveryOptions = this.getRecoveryOptions(status);
// Create error toast with retry
if (window.toastManager) {
const toastId = window.toastManager.error(message, 'Error', 0);
// Find toast element by ID
const toastElement = window.toastManager.container.querySelector(
`[data-toast-id="${toastId}"]`
) || Array.from(window.toastManager.container.children).find(
el => el.getAttribute('data-toast-id') === String(toastId)
);
if (toastElement) {
const retryContainer = document.createElement('div');
retryContainer.className = 'error-retry-container';
const retryBtn = document.createElement('button');
retryBtn.className = 'error-retry-btn';
retryBtn.type = 'button';
retryBtn.setAttribute('aria-label', 'Retry');
retryBtn.textContent = 'Retry';
retryBtn.onclick = async () => {
retryBtn.disabled = true;
retryBtn.textContent = 'Retrying...';
try {
await retryCallback();
window.toastManager.dismiss(toastId);
window.toastManager.success('Operation completed successfully', 'Success');
} catch (error) {
retryBtn.disabled = false;
retryBtn.textContent = 'Retry';
window.toastManager.error(
this.getUserFriendlyMessage(0, error),
'Retry Failed'
);
}
};
retryContainer.appendChild(retryBtn);
// Add recovery options if available
if (recoveryOptions.length > 0) {
const recoveryDiv = document.createElement('div');
recoveryDiv.className = 'error-recovery-options';
recoveryOptions.forEach(option => {
const btn = document.createElement('button');
btn.className = 'error-recovery-btn';
btn.textContent = option.label;
btn.onclick = option.action;
recoveryDiv.appendChild(btn);
});
retryContainer.appendChild(recoveryDiv);
}
toastElement.appendChild(retryContainer);
}
return toastId;
}
// Fallback to console
console.error('Error:', message);
return null;
}
showError(message, title = 'Error') {
if (this.isDuplicateError(message)) {
console.warn('Duplicate error suppressed:', message);
return;
}
try {
if (window.toastManager && typeof window.toastManager.error === 'function') {
window.toastManager.error(message, title);
} else {
console.error(title + ':', message);
}
} catch (e) {
console.error(title + ':', message);
}
}
/**
* Check if an error message was recently shown (deduplication)
*/
isDuplicateError(message) {
const now = Date.now();
const lastShown = this.recentErrors.get(message);
if (lastShown && (now - lastShown) < this.errorDeduplicationWindow) {
return true; // This error was shown recently
}
// Update the timestamp for this error
this.recentErrors.set(message, now);
// Clean up old entries (older than deduplication window)
for (const [msg, timestamp] of this.recentErrors.entries()) {
if (now - timestamp >= this.errorDeduplicationWindow) {
this.recentErrors.delete(msg);
}
}
return false;
}
/**
* Recovery Options
*/
getRecoveryOptions(status) {
const options = [];
switch (status) {
case 0:
// Connection errors - offer refresh option
options.push({
label: 'Refresh',
action: () => {
window.location.reload();
}
});
break;
case 401:
options.push({
label: 'Go to Login',
action: () => {
window.location.href = '/auth/login';
}
});
break;
case 403:
options.push({
label: 'Go to Dashboard',
action: () => {
window.location.href = '/main/dashboard';
}
});
break;
case 404:
options.push({
label: 'Go to Dashboard',
action: () => {
window.location.href = '/main/dashboard';
}
});
options.push({
label: 'Go Back',
action: () => {
window.history.back();
}
});
break;
case 500:
case 502:
case 503:
case 504:
options.push({
label: 'Refresh',
action: () => {
window.location.reload();
}
});
break;
}
return options;
}
/**
* Offline Queue Management
* Stores method, headers, and body in a replay-safe form so POST/PUT replay correctly after JSON round-trip.
*/
async queueForOffline(url, options, errorId = null) {
const opts = options || {};
let method = (opts.method || 'GET').toUpperCase();
let headers = {};
let body = null;
if (opts.headers) {
if (opts.headers instanceof Headers) {
opts.headers.forEach((v, k) => { headers[k] = v; });
} else if (typeof opts.headers === 'object') {
headers = { ...opts.headers };
}
}
if (opts.body !== undefined && opts.body !== null) {
if (typeof opts.body === 'string') {
body = opts.body;
} else if (opts.body instanceof Blob) {
try {
body = await opts.body.text();
} catch (e) {
console.warn('Offline queue: could not read body as text, skipping queue', e);
return;
}
} else if (opts.body instanceof ArrayBuffer) {
body = new TextDecoder().decode(opts.body);
} else if (typeof opts.body.toString === 'function') {
body = opts.body.toString();
} else {
try {
body = JSON.stringify(opts.body);
} catch (e) {
console.warn('Offline queue: could not serialize body, skipping queue', e);
return;
}
}
}
const queueItem = {
url,
method,
headers,
body,
errorId,
timestamp: Date.now(),
retries: 0
};
this.offlineQueue.push(queueItem);
this.updateOfflineQueueIndicator();
this.saveOfflineQueue();
}
saveOfflineQueue() {
try {
localStorage.setItem('offline_queue', JSON.stringify(this.offlineQueue));
} catch (e) {
console.warn('Failed to save offline queue:', e);
}
}
loadOfflineQueue() {
try {
const stored = localStorage.getItem('offline_queue');
if (stored) {
this.offlineQueue = JSON.parse(stored);
this.updateOfflineQueueIndicator();
}
} catch (e) {
console.warn('Failed to load offline queue:', e);
}
}
async processOfflineQueue() {
if (this.offlineQueue.length === 0) return;
const queue = [...this.offlineQueue];
this.offlineQueue = [];
for (const item of queue) {
try {
let fetchOptions;
if (item.method !== undefined || item.body !== undefined) {
fetchOptions = {
method: item.method || 'GET',
headers: item.headers || {}
};
if (item.body != null) {
fetchOptions.body = item.body;
}
} else {
fetchOptions = item.options || { method: 'GET' };
}
const response = await fetch(item.url, fetchOptions);
if (response.ok && item.errorId) {
window.toastManager?.dismiss(item.errorId);
}
} catch (error) {
this.offlineQueue.push(item);
}
}
this.updateOfflineQueueIndicator();
this.saveOfflineQueue();
}
updateOfflineQueueIndicator() {
const countElement = document.getElementById('offline-queue-count');
if (countElement) {
const count = this.offlineQueue.length;
if (count > 0) {
countElement.textContent = `(${count} pending)`;
countElement.style.display = 'inline';
} else {
countElement.style.display = 'none';
}
}
}
setupOfflineQueue() {
// Load existing queue on init
this.loadOfflineQueue();
// Process queue when online
if (this.isOnline && this.offlineQueue.length > 0) {
this.processOfflineQueue();
}
}
/**
* Global Error Handlers
*/
setupGlobalErrorHandlers() {
// JavaScript errors
window.addEventListener('error', (event) => {
this.handleJavaScriptError(event.error, event.message, event.filename, event.lineno);
});
// Unhandled promise rejections
window.addEventListener('unhandledrejection', (event) => {
this.handleUnhandledRejection(event.reason);
});
}
handleJavaScriptError(error, message, filename, lineno) {
if (this.shouldIgnoreFrontendNoise(error, message)) {
return;
}
const userFriendlyMessage = 'An unexpected error occurred. Please refresh the page or contact support if the problem persists.';
// Check if we've shown this error recently
if (this.isDuplicateError(userFriendlyMessage)) {
// Log to console but don't show duplicate toast
console.error('JavaScript Error (duplicate suppressed):', {
error,
message,
filename,
lineno
});
return;
}
this.showError(userFriendlyMessage, 'Application Error');
// Log to console for debugging
console.error('JavaScript Error:', {
error,
message,
filename,
lineno
});
}
handleUnhandledRejection(reason) {
if (this.shouldIgnoreFrontendNoise(reason, reason?.message)) {
return;
}
const userFriendlyMessage = 'An operation failed unexpectedly. Please try again or contact support if the problem persists.';
// Check if we've shown this error recently
if (this.isDuplicateError(userFriendlyMessage)) {
// Log to console but don't show duplicate toast
console.error('Unhandled Rejection (duplicate suppressed):', reason);
return;
}
this.showError(userFriendlyMessage, 'Operation Failed');
// Log to console for debugging
console.error('Unhandled Rejection:', reason);
}
/**
* Graceful Degradation
*/
setupGracefulDegradation() {
// Check for required features
this.checkRequiredFeatures();
// Setup feature fallbacks
this.setupFeatureFallbacks();
}
checkRequiredFeatures() {
// Only check for truly critical features required for app functionality
// serviceWorker is optional (PWA enhancement) and only works over HTTPS
const features = {
'localStorage': typeof Storage !== 'undefined',
'fetch': typeof fetch !== 'undefined'
};
const missing = Object.entries(features)
.filter(([_, available]) => !available)
.map(([name]) => name);
if (missing.length > 0) {
console.warn('Missing features:', missing);
this.showError(
`Some features may not work properly: ${missing.join(', ')}. Please update your browser.`,
'Browser Compatibility'
);
}
// Log serviceWorker availability for debugging (but don't show warning)
if (!('serviceWorker' in navigator)) {
const isSecureContext = window.isSecureContext || location.protocol === 'https:' || location.hostname === 'localhost' || location.hostname === '127.0.0.1';
if (!isSecureContext) {
console.debug('ServiceWorker not available: requires HTTPS (or localhost)');
} else {
console.debug('ServiceWorker not available: browser does not support it');
}
}
}
setupFeatureFallbacks() {
// Fallback for fetch if not available
if (typeof fetch === 'undefined') {
console.warn('Fetch API not available, using XMLHttpRequest fallback');
this.polyfillFetch();
}
// Fallback for localStorage
if (typeof Storage === 'undefined' || typeof localStorage === 'undefined') {
console.warn('LocalStorage not available, using memory storage');
this.polyfillLocalStorage();
}
}
polyfillFetch() {
// Simple fetch polyfill using XMLHttpRequest
window.fetch = function(url, options = {}) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const method = options.method || 'GET';
const headers = options.headers || {};
xhr.open(method, url, true);
// Set headers
Object.keys(headers).forEach(key => {
xhr.setRequestHeader(key, headers[key]);
});
// Handle response
xhr.onload = function() {
const response = {
ok: xhr.status >= 200 && xhr.status < 300,
status: xhr.status,
statusText: xhr.statusText,
headers: {
get: function(name) {
return xhr.getResponseHeader(name);
}
},
text: function() {
return Promise.resolve(xhr.responseText);
},
json: function() {
try {
return Promise.resolve(JSON.parse(xhr.responseText));
} catch (e) {
return Promise.reject(new Error('Invalid JSON response'));
}
},
blob: function() {
return Promise.resolve(new Blob([xhr.response]));
},
arrayBuffer: function() {
return Promise.resolve(xhr.response);
}
};
if (xhr.status >= 200 && xhr.status < 300) {
resolve(response);
} else {
reject(new Error(`HTTP ${xhr.status}: ${xhr.statusText}`));
}
};
xhr.onerror = function() {
reject(new Error('Network request failed'));
};
xhr.ontimeout = function() {
reject(new Error('Request timeout'));
};
// Set timeout if specified
if (options.timeout) {
xhr.timeout = options.timeout;
}
// Send request
if (options.body) {
if (typeof options.body === 'string') {
xhr.send(options.body);
} else if (options.body instanceof FormData) {
xhr.send(options.body);
} else {
xhr.send(JSON.stringify(options.body));
}
} else {
xhr.send();
}
});
};
}
polyfillLocalStorage() {
// In-memory storage fallback
const memoryStorage = {};
window.localStorage = {
getItem: function(key) {
return memoryStorage[key] || null;
},
setItem: function(key, value) {
try {
memoryStorage[key] = String(value);
// Dispatch storage event for compatibility
window.dispatchEvent(new Event('storage'));
} catch (e) {
console.warn('Memory storage setItem failed:', e);
}
},
removeItem: function(key) {
try {
delete memoryStorage[key];
window.dispatchEvent(new Event('storage'));
} catch (e) {
console.warn('Memory storage removeItem failed:', e);
}
},
clear: function() {
try {
Object.keys(memoryStorage).forEach(key => {
delete memoryStorage[key];
});
window.dispatchEvent(new Event('storage'));
} catch (e) {
console.warn('Memory storage clear failed:', e);
}
},
get length() {
return Object.keys(memoryStorage).length;
},
key: function(index) {
const keys = Object.keys(memoryStorage);
return keys[index] || null;
}
};
// Also polyfill sessionStorage with same in-memory implementation
if (typeof sessionStorage === 'undefined') {
window.sessionStorage = Object.create(window.localStorage);
}
}
retryFailedOperations() {
// Retry operations from retry queue
const queue = [...this.retryQueue];
this.retryQueue = [];
queue.forEach(operation => {
try {
operation();
} catch (error) {
console.error('Failed to retry operation:', error);
}
});
}
shouldIgnoreFrontendNoise(error, message) {
const normalizedMessage = String(message || error?.message || '').toLowerCase();
// Known benign ResizeObserver warning triggered by various UI libraries/browsers
if (normalizedMessage.includes('resizeobserver loop limit exceeded') ||
normalizedMessage.includes('resizeobserver loop completed with undelivered notifications')) {
console.debug('Ignored benign ResizeObserver warning:', message || error);
return true;
}
return false;
}
}
// Initialize enhanced error handler
window.enhancedErrorHandler = new EnhancedErrorHandler();
// Remove offline indicator when online
document.addEventListener('DOMContentLoaded', () => {
if (navigator.onLine) {
const indicator = document.getElementById('offline-indicator');
if (indicator) {
indicator.remove();
}
}
});