mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 14:31:12 -05:00
217 lines
5.6 KiB
TypeScript
217 lines
5.6 KiB
TypeScript
/**
|
|
* Timeout Detection & Recovery
|
|
*
|
|
* Detects timeout errors from various sources (fetch, Supabase, edge functions)
|
|
* and provides recovery strategies.
|
|
*
|
|
* Part of Sacred Pipeline Phase 4: Transaction Resilience
|
|
*/
|
|
|
|
import { logger } from './logger';
|
|
|
|
export interface TimeoutError extends Error {
|
|
isTimeout: true;
|
|
source: 'fetch' | 'supabase' | 'edge-function' | 'database' | 'unknown';
|
|
originalError?: unknown;
|
|
duration?: number;
|
|
}
|
|
|
|
/**
|
|
* Check if an error is a timeout error
|
|
*/
|
|
export function isTimeoutError(error: unknown): boolean {
|
|
if (!error) return false;
|
|
|
|
// Check for AbortController timeout
|
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
return true;
|
|
}
|
|
|
|
// Check for fetch timeout
|
|
if (error instanceof TypeError && error.message.includes('aborted')) {
|
|
return true;
|
|
}
|
|
|
|
// Check error message for timeout keywords
|
|
if (error instanceof Error) {
|
|
const message = error.message.toLowerCase();
|
|
return (
|
|
message.includes('timeout') ||
|
|
message.includes('timed out') ||
|
|
message.includes('deadline exceeded') ||
|
|
message.includes('request aborted') ||
|
|
message.includes('etimedout')
|
|
);
|
|
}
|
|
|
|
// Check Supabase/HTTP timeout status codes
|
|
if (error && typeof error === 'object') {
|
|
const errorObj = error as { status?: number; code?: string; message?: string };
|
|
|
|
// HTTP 408 Request Timeout
|
|
if (errorObj.status === 408) return true;
|
|
|
|
// HTTP 504 Gateway Timeout
|
|
if (errorObj.status === 504) return true;
|
|
|
|
// Supabase timeout codes
|
|
if (errorObj.code === 'PGRST301') return true; // Connection timeout
|
|
if (errorObj.code === '57014') return true; // PostgreSQL query cancelled
|
|
|
|
// Check message
|
|
if (errorObj.message?.toLowerCase().includes('timeout')) return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Wrap an error as a TimeoutError with source information
|
|
*/
|
|
export function wrapAsTimeoutError(
|
|
error: unknown,
|
|
source: TimeoutError['source'],
|
|
duration?: number
|
|
): TimeoutError {
|
|
const message = error instanceof Error ? error.message : 'Operation timed out';
|
|
const timeoutError = new Error(message) as TimeoutError;
|
|
|
|
timeoutError.name = 'TimeoutError';
|
|
timeoutError.isTimeout = true;
|
|
timeoutError.source = source;
|
|
timeoutError.originalError = error;
|
|
timeoutError.duration = duration;
|
|
|
|
return timeoutError;
|
|
}
|
|
|
|
/**
|
|
* Execute a function with a timeout wrapper
|
|
*
|
|
* @param fn - Function to execute
|
|
* @param timeoutMs - Timeout in milliseconds
|
|
* @param source - Source identifier for error tracking
|
|
* @returns Promise that resolves or rejects with timeout
|
|
*/
|
|
export async function withTimeout<T>(
|
|
fn: () => Promise<T>,
|
|
timeoutMs: number,
|
|
source: TimeoutError['source'] = 'unknown'
|
|
): Promise<T> {
|
|
const startTime = Date.now();
|
|
const controller = new AbortController();
|
|
|
|
const timeoutId = setTimeout(() => {
|
|
controller.abort();
|
|
}, timeoutMs);
|
|
|
|
try {
|
|
// Execute the function with abort signal if supported
|
|
const result = await fn();
|
|
clearTimeout(timeoutId);
|
|
return result;
|
|
} catch (error) {
|
|
clearTimeout(timeoutId);
|
|
const duration = Date.now() - startTime;
|
|
|
|
// Check if error is timeout-related
|
|
if (isTimeoutError(error) || controller.signal.aborted) {
|
|
const timeoutError = wrapAsTimeoutError(error, source, duration);
|
|
|
|
logger.error('Operation timed out', {
|
|
source,
|
|
duration,
|
|
timeoutMs,
|
|
originalError: error instanceof Error ? error.message : String(error)
|
|
});
|
|
|
|
throw timeoutError;
|
|
}
|
|
|
|
// Re-throw non-timeout errors
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Categorize timeout severity for recovery strategy
|
|
*/
|
|
export function getTimeoutSeverity(error: TimeoutError): 'minor' | 'moderate' | 'critical' {
|
|
const { duration, source } = error;
|
|
|
|
// No duration means immediate abort - likely user action or critical failure
|
|
if (!duration) return 'critical';
|
|
|
|
// Database/edge function timeouts are more critical
|
|
if (source === 'database' || source === 'edge-function') {
|
|
if (duration > 30000) return 'critical'; // >30s
|
|
if (duration > 10000) return 'moderate'; // >10s
|
|
return 'minor';
|
|
}
|
|
|
|
// Fetch timeouts
|
|
if (source === 'fetch') {
|
|
if (duration > 60000) return 'critical'; // >60s
|
|
if (duration > 20000) return 'moderate'; // >20s
|
|
return 'minor';
|
|
}
|
|
|
|
return 'moderate';
|
|
}
|
|
|
|
/**
|
|
* Get recommended retry strategy based on timeout error
|
|
*/
|
|
export function getTimeoutRetryStrategy(error: TimeoutError): {
|
|
shouldRetry: boolean;
|
|
delayMs: number;
|
|
maxAttempts: number;
|
|
increaseTimeout: boolean;
|
|
} {
|
|
const severity = getTimeoutSeverity(error);
|
|
|
|
switch (severity) {
|
|
case 'minor':
|
|
return {
|
|
shouldRetry: true,
|
|
delayMs: 1000,
|
|
maxAttempts: 3,
|
|
increaseTimeout: false,
|
|
};
|
|
|
|
case 'moderate':
|
|
return {
|
|
shouldRetry: true,
|
|
delayMs: 3000,
|
|
maxAttempts: 2,
|
|
increaseTimeout: true, // Increase timeout by 50%
|
|
};
|
|
|
|
case 'critical':
|
|
return {
|
|
shouldRetry: false, // Don't auto-retry critical timeouts
|
|
delayMs: 5000,
|
|
maxAttempts: 1,
|
|
increaseTimeout: true,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* User-friendly timeout error message
|
|
*/
|
|
export function getTimeoutErrorMessage(error: TimeoutError): string {
|
|
const severity = getTimeoutSeverity(error);
|
|
|
|
switch (severity) {
|
|
case 'minor':
|
|
return 'The request took longer than expected. Retrying...';
|
|
|
|
case 'moderate':
|
|
return 'The server is taking longer than usual to respond. Please wait while we retry.';
|
|
|
|
case 'critical':
|
|
return 'The operation timed out. Please check your connection and try again.';
|
|
}
|
|
}
|