Some checks failed
🚀 Build & Deploy Image / Determine Build Necessity (push) Failing after 10m14s
🚀 Build & Deploy Image / Build Runtime Base Image (push) Has been skipped
🚀 Build & Deploy Image / Build Docker Image (push) Has been skipped
🚀 Build & Deploy Image / Run Tests & Quality Checks (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Staging (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Production (push) Has been skipped
Security Vulnerability Scan / Check for Dependency Changes (push) Failing after 11m25s
Security Vulnerability Scan / Composer Security Audit (push) Has been cancelled
- Remove middleware reference from Gitea Traefik labels (caused routing issues) - Optimize Gitea connection pool settings (MAX_IDLE_CONNS=30, authentication_timeout=180s) - Add explicit service reference in Traefik labels - Fix intermittent 504 timeouts by improving PostgreSQL connection handling Fixes Gitea unreachability via git.michaelschiemer.de
348 lines
11 KiB
JavaScript
348 lines
11 KiB
JavaScript
/**
|
||
* Error Boundary for LiveComponents
|
||
*
|
||
* Provides automatic error handling, retry mechanisms, and error recovery
|
||
* for LiveComponent operations.
|
||
*/
|
||
|
||
// Note: LiveComponentError type is defined in types/livecomponent.d.ts
|
||
// This is a runtime implementation, so we don't need to import the type
|
||
|
||
export class ErrorBoundary {
|
||
constructor(liveComponentManager) {
|
||
this.manager = liveComponentManager;
|
||
this.retryStrategies = new Map();
|
||
this.errorHandlers = new Map();
|
||
this.maxRetries = 3;
|
||
this.retryDelays = [1000, 2000, 5000]; // Progressive backoff
|
||
}
|
||
|
||
/**
|
||
* Handle error from component action
|
||
*
|
||
* @param {string} componentId - Component ID
|
||
* @param {string} action - Action method name
|
||
* @param {Error|LiveComponentError} error - Error object
|
||
* @param {Object} context - Additional context
|
||
* @returns {Promise<boolean>} True if error was handled, false otherwise
|
||
*/
|
||
async handleError(componentId, action, error, context = {}) {
|
||
console.error(`[ErrorBoundary] Error in ${componentId}.${action}:`, error);
|
||
|
||
// Convert to standardized error format
|
||
const standardizedError = this.standardizeError(error, componentId, action);
|
||
|
||
// Check for custom error handler
|
||
const handler = this.errorHandlers.get(componentId);
|
||
if (handler) {
|
||
try {
|
||
const handled = await handler(standardizedError, context);
|
||
if (handled) {
|
||
return true;
|
||
}
|
||
} catch (handlerError) {
|
||
console.error('[ErrorBoundary] Error handler failed:', handlerError);
|
||
}
|
||
}
|
||
|
||
// Check if error is retryable
|
||
if (this.isRetryable(standardizedError)) {
|
||
const retried = await this.retryOperation(componentId, action, context, standardizedError);
|
||
if (retried) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// Show error to user
|
||
this.showError(componentId, standardizedError);
|
||
|
||
// Dispatch error event
|
||
this.dispatchErrorEvent(componentId, standardizedError);
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Standardize error format
|
||
*
|
||
* @param {Error|LiveComponentError|Object} error - Error object
|
||
* @param {string} componentId - Component ID
|
||
* @param {string} action - Action method name
|
||
* @returns {LiveComponentError} Standardized error
|
||
*/
|
||
standardizeError(error, componentId, action) {
|
||
// If already standardized
|
||
if (error && typeof error === 'object' && 'code' in error && 'message' in error) {
|
||
return {
|
||
code: error.code,
|
||
message: error.message,
|
||
details: error.details || {},
|
||
componentId: error.componentId || componentId,
|
||
action: error.action || action,
|
||
timestamp: error.timestamp || Date.now()
|
||
};
|
||
}
|
||
|
||
// If Error object
|
||
if (error instanceof Error) {
|
||
return {
|
||
code: 'INTERNAL_ERROR',
|
||
message: error.message,
|
||
details: {
|
||
stack: error.stack,
|
||
name: error.name
|
||
},
|
||
componentId,
|
||
action,
|
||
timestamp: Date.now()
|
||
};
|
||
}
|
||
|
||
// If string
|
||
if (typeof error === 'string') {
|
||
return {
|
||
code: 'INTERNAL_ERROR',
|
||
message: error,
|
||
componentId,
|
||
action,
|
||
timestamp: Date.now()
|
||
};
|
||
}
|
||
|
||
// Default
|
||
return {
|
||
code: 'INTERNAL_ERROR',
|
||
message: 'An unknown error occurred',
|
||
details: { original: error },
|
||
componentId,
|
||
action,
|
||
timestamp: Date.now()
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Check if error is retryable
|
||
*
|
||
* @param {LiveComponentError} error - Standardized error
|
||
* @returns {boolean} True if error is retryable
|
||
*/
|
||
isRetryable(error) {
|
||
const retryableCodes = [
|
||
'RATE_LIMIT_EXCEEDED',
|
||
'STATE_CONFLICT',
|
||
'INTERNAL_ERROR'
|
||
];
|
||
|
||
return retryableCodes.includes(error.code);
|
||
}
|
||
|
||
/**
|
||
* Retry operation with exponential backoff
|
||
*
|
||
* @param {string} componentId - Component ID
|
||
* @param {string} action - Action method name
|
||
* @param {Object} context - Operation context
|
||
* @param {LiveComponentError} error - Error that occurred
|
||
* @returns {Promise<boolean>} True if retry succeeded
|
||
*/
|
||
async retryOperation(componentId, action, context, error) {
|
||
const retryKey = `${componentId}:${action}`;
|
||
const retryCount = this.retryStrategies.get(retryKey) || 0;
|
||
|
||
if (retryCount >= this.maxRetries) {
|
||
console.warn(`[ErrorBoundary] Max retries exceeded for ${retryKey}`);
|
||
this.retryStrategies.delete(retryKey);
|
||
return false;
|
||
}
|
||
|
||
// Calculate delay (progressive backoff)
|
||
const delay = this.retryDelays[retryCount] || this.retryDelays[this.retryDelays.length - 1];
|
||
|
||
console.log(`[ErrorBoundary] Retrying ${retryKey} in ${delay}ms (attempt ${retryCount + 1}/${this.maxRetries})`);
|
||
|
||
// Update retry count
|
||
this.retryStrategies.set(retryKey, retryCount + 1);
|
||
|
||
// Wait before retry
|
||
await new Promise(resolve => setTimeout(resolve, delay));
|
||
|
||
try {
|
||
// Retry the operation
|
||
const result = await this.manager.executeAction(
|
||
componentId,
|
||
action,
|
||
context.params || {},
|
||
context.fragments || null
|
||
);
|
||
|
||
// Success - clear retry count
|
||
this.retryStrategies.delete(retryKey);
|
||
console.log(`[ErrorBoundary] Retry succeeded for ${retryKey}`);
|
||
return true;
|
||
|
||
} catch (retryError) {
|
||
// Retry failed - will be handled by next retry or error handler
|
||
console.warn(`[ErrorBoundary] Retry failed for ${retryKey}:`, retryError);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Show error to user
|
||
*
|
||
* @param {string} componentId - Component ID
|
||
* @param {LiveComponentError} error - Standardized error
|
||
*/
|
||
showError(componentId, error) {
|
||
const config = this.manager.components.get(componentId);
|
||
if (!config) return;
|
||
|
||
// Remove existing error
|
||
const existingError = config.element.querySelector('.livecomponent-error-boundary');
|
||
if (existingError) {
|
||
existingError.remove();
|
||
}
|
||
|
||
// Create error element
|
||
const errorEl = document.createElement('div');
|
||
errorEl.className = 'livecomponent-error-boundary';
|
||
errorEl.style.cssText = `
|
||
padding: 1rem;
|
||
background: #fee;
|
||
color: #c00;
|
||
border: 1px solid #faa;
|
||
border-radius: 4px;
|
||
margin-bottom: 1rem;
|
||
`;
|
||
|
||
// Error message
|
||
const messageEl = document.createElement('div');
|
||
messageEl.style.fontWeight = 'bold';
|
||
messageEl.textContent = error.message;
|
||
errorEl.appendChild(messageEl);
|
||
|
||
// Error code (if not generic)
|
||
if (error.code !== 'INTERNAL_ERROR') {
|
||
const codeEl = document.createElement('div');
|
||
codeEl.style.fontSize = '0.875rem';
|
||
codeEl.style.marginTop = '0.5rem';
|
||
codeEl.style.color = '#666';
|
||
codeEl.textContent = `Error Code: ${error.code}`;
|
||
errorEl.appendChild(codeEl);
|
||
}
|
||
|
||
// Retry button (if retryable)
|
||
if (this.isRetryable(error)) {
|
||
const retryBtn = document.createElement('button');
|
||
retryBtn.textContent = 'Retry';
|
||
retryBtn.style.cssText = `
|
||
margin-top: 0.5rem;
|
||
padding: 0.5rem 1rem;
|
||
background: #c00;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
`;
|
||
retryBtn.addEventListener('click', async () => {
|
||
errorEl.remove();
|
||
await this.retryOperation(componentId, error.action || '', {}, error);
|
||
});
|
||
errorEl.appendChild(retryBtn);
|
||
}
|
||
|
||
// Close button
|
||
const closeBtn = document.createElement('button');
|
||
closeBtn.textContent = '×';
|
||
closeBtn.style.cssText = `
|
||
float: right;
|
||
background: transparent;
|
||
border: none;
|
||
font-size: 1.5rem;
|
||
cursor: pointer;
|
||
color: #c00;
|
||
`;
|
||
closeBtn.addEventListener('click', () => {
|
||
errorEl.remove();
|
||
});
|
||
errorEl.appendChild(closeBtn);
|
||
|
||
// Insert at top of component
|
||
config.element.insertAdjacentElement('afterbegin', errorEl);
|
||
|
||
// Auto-remove after 10 seconds
|
||
setTimeout(() => {
|
||
if (errorEl.parentNode) {
|
||
errorEl.remove();
|
||
}
|
||
}, 10000);
|
||
}
|
||
|
||
/**
|
||
* Dispatch error event
|
||
*
|
||
* @param {string} componentId - Component ID
|
||
* @param {LiveComponentError} error - Standardized error
|
||
*/
|
||
dispatchErrorEvent(componentId, error) {
|
||
// Dispatch custom DOM event
|
||
const event = new CustomEvent('livecomponent:error', {
|
||
detail: {
|
||
componentId,
|
||
error
|
||
},
|
||
bubbles: true
|
||
});
|
||
|
||
const config = this.manager.components.get(componentId);
|
||
if (config) {
|
||
config.element.dispatchEvent(event);
|
||
}
|
||
|
||
// Also dispatch on document
|
||
document.dispatchEvent(event);
|
||
}
|
||
|
||
/**
|
||
* Register custom error handler for component
|
||
*
|
||
* @param {string} componentId - Component ID
|
||
* @param {Function} handler - Error handler function
|
||
*/
|
||
registerErrorHandler(componentId, handler) {
|
||
if (typeof handler !== 'function') {
|
||
throw new Error('Error handler must be a function');
|
||
}
|
||
|
||
this.errorHandlers.set(componentId, handler);
|
||
}
|
||
|
||
/**
|
||
* Clear error handler for component
|
||
*
|
||
* @param {string} componentId - Component ID
|
||
*/
|
||
clearErrorHandler(componentId) {
|
||
this.errorHandlers.delete(componentId);
|
||
}
|
||
|
||
/**
|
||
* Clear retry strategy for component/action
|
||
*
|
||
* @param {string} componentId - Component ID
|
||
* @param {string} action - Action method name
|
||
*/
|
||
clearRetryStrategy(componentId, action) {
|
||
const retryKey = `${componentId}:${action}`;
|
||
this.retryStrategies.delete(retryKey);
|
||
}
|
||
|
||
/**
|
||
* Reset all retry strategies
|
||
*/
|
||
resetRetryStrategies() {
|
||
this.retryStrategies.clear();
|
||
}
|
||
}
|
||
|