diff --git a/src/lib/entitySubmissionHelpers.ts b/src/lib/entitySubmissionHelpers.ts index d5d7021f..2eb66d15 100644 --- a/src/lib/entitySubmissionHelpers.ts +++ b/src/lib/entitySubmissionHelpers.ts @@ -370,40 +370,98 @@ async function submitCompositeCreation( } } - // Use RPC to create submission with items atomically - const { data: result, error } = await supabase.rpc('create_submission_with_items', { - p_user_id: userId, - p_submission_type: uploadedPrimary.type, - p_content: { action: 'create' } as unknown as Json, - p_items: submissionItems as unknown as Json[] - }); + // Use RPC to create submission with items atomically with retry logic + const { withRetry } = await import('./retryHelpers'); + const { toast } = await import('@/hooks/use-toast'); + + const result = await withRetry( + async () => { + const { data, error } = await supabase.rpc('create_submission_with_items', { + p_user_id: userId, + p_submission_type: uploadedPrimary.type, + p_content: { action: 'create' } as unknown as Json, + p_items: submissionItems as unknown as Json[] + }); - if (error) { - // Extract Supabase error details for better error logging - const supabaseError = error as { message?: string; code?: string; details?: string; hint?: string }; - const errorMessage = supabaseError.message || 'Unknown error'; - const errorCode = supabaseError.code; - const errorDetails = supabaseError.details; - const errorHint = supabaseError.hint; - - // Create proper Error instance with enhanced context - const enhancedError = new Error( - `Composite submission failed: ${errorMessage}${errorDetails ? `\nDetails: ${errorDetails}` : ''}${errorHint ? `\nHint: ${errorHint}` : ''}` - ); - - handleError(enhancedError, { + if (error) { + // Extract Supabase error details for better error logging + const supabaseError = error as { message?: string; code?: string; details?: string; hint?: string }; + const errorMessage = supabaseError.message || 'Unknown error'; + const errorCode = supabaseError.code; + const errorDetails = supabaseError.details; + const errorHint = supabaseError.hint; + + // Create proper Error instance with enhanced context + const enhancedError = new Error( + `Composite submission failed: ${errorMessage}${errorDetails ? `\nDetails: ${errorDetails}` : ''}${errorHint ? `\nHint: ${errorHint}` : ''}` + ); + + // Attach Supabase metadata for retry logic + (enhancedError as any).supabaseCode = errorCode; + (enhancedError as any).supabaseDetails = errorDetails; + (enhancedError as any).supabaseHint = errorHint; + + throw enhancedError; + } + + return data; + }, + { + maxAttempts: 3, + baseDelay: 1000, + maxDelay: 10000, + onRetry: (attempt, error, delay) => { + logger.warn('Retrying composite submission', { + attempt, + maxAttempts: 3, + delay, + error: error instanceof Error ? error.message : String(error), + primaryType: uploadedPrimary.type, + dependencyCount: dependencies.length + }); + + // Show user feedback + toast({ + title: 'Submission retry', + description: `Attempt ${attempt}/3 - Retrying in ${Math.round(delay / 1000)}s...`, + }); + }, + shouldRetry: (error) => { + // Don't retry validation errors + if (error instanceof Error) { + const message = error.message.toLowerCase(); + if (message.includes('required')) return false; + if (message.includes('banned')) return false; + if (message.includes('suspended')) return false; + if (message.includes('slug')) return false; + if (message.includes('already exists')) return false; + if (message.includes('duplicate')) return false; + if (message.includes('permission')) return false; + if (message.includes('forbidden')) return false; + if (message.includes('unauthorized')) return false; + } + + // Use default retryable error detection from retryHelpers + const { isRetryableError } = require('./retryHelpers'); + return isRetryableError(error); + } + } + ).catch((error) => { + // Final failure - log and throw + handleError(error, { action: 'Composite submission', metadata: { primaryType: uploadedPrimary.type, dependencyCount: dependencies.length, - supabaseCode: errorCode, - supabaseDetails: errorDetails, - supabaseHint: errorHint + supabaseCode: (error as any).supabaseCode, + supabaseDetails: (error as any).supabaseDetails, + supabaseHint: (error as any).supabaseHint, + retriesExhausted: true }, }); - throw enhancedError; - } + throw error; + }); return { submitted: true, submissionId: result }; } diff --git a/src/lib/errorHandler.ts b/src/lib/errorHandler.ts index 923e58cd..da06be26 100644 --- a/src/lib/errorHandler.ts +++ b/src/lib/errorHandler.ts @@ -113,7 +113,12 @@ export const handleError = ( p_error_message: errorMessage, p_error_stack: stack, p_user_agent: navigator.userAgent, - p_breadcrumbs: JSON.stringify(breadcrumbs), + p_breadcrumbs: JSON.stringify({ + ...breadcrumbs, + isRetry: context.metadata?.isRetry || false, + attempt: context.metadata?.attempt, + retriesExhausted: context.metadata?.retriesExhausted || false, + }), p_timezone: envContext.timezone, p_referrer: document.referrer || undefined, p_duration_ms: context.duration, @@ -126,11 +131,14 @@ export const handleError = ( logger.error('Failed to capture error context', { logError }); } - // Show user-friendly toast with error ID - toast.error(context.action, { - description: `${errorMessage}\n\nReference ID: ${shortErrorId}`, - duration: 5000, - }); + // Show user-friendly toast with error ID (skip for retry attempts) + const isRetry = context.metadata?.isRetry === true || context.metadata?.attempt; + if (!isRetry) { + toast.error(context.action, { + description: `${errorMessage}\n\nReference ID: ${shortErrorId}`, + duration: 5000, + }); + } return errorId; }; diff --git a/src/lib/retryHelpers.ts b/src/lib/retryHelpers.ts new file mode 100644 index 00000000..d44c0980 --- /dev/null +++ b/src/lib/retryHelpers.ts @@ -0,0 +1,190 @@ +/** + * Retry utility with exponential backoff + * Handles transient failures gracefully with configurable retry logic + */ + +import { logger } from './logger'; + +export interface RetryOptions { + /** Maximum number of attempts (default: 3) */ + maxAttempts?: number; + /** Base delay in milliseconds (default: 1000) */ + baseDelay?: number; + /** Maximum delay in milliseconds (default: 10000) */ + maxDelay?: number; + /** Multiplier for exponential backoff (default: 2) */ + backoffMultiplier?: number; + /** Add jitter to prevent thundering herd (default: true) */ + jitter?: boolean; + /** Callback invoked before each retry attempt */ + onRetry?: (attempt: number, error: unknown, delay: number) => void; + /** Custom function to determine if error is retryable (default: isRetryableError) */ + shouldRetry?: (error: unknown) => boolean; +} + +/** + * Determines if an error is transient and retryable + * @param error - The error to check + * @returns true if error is retryable, false otherwise + */ +export function isRetryableError(error: unknown): boolean { + // Network/timeout errors from fetch + if (error instanceof TypeError && error.message.includes('fetch')) { + return true; + } + + // Network/timeout errors + if (error instanceof Error) { + const message = error.message.toLowerCase(); + if (message.includes('network') || + message.includes('timeout') || + message.includes('connection') || + message.includes('econnrefused') || + message.includes('enotfound')) { + return true; + } + } + + // Supabase/PostgreSQL errors + if (error && typeof error === 'object') { + const supabaseError = error as { code?: string; status?: number }; + + // Connection/timeout errors + if (supabaseError.code === 'PGRST301') return true; // Connection timeout + if (supabaseError.code === 'PGRST204') return true; // Temporary failure + if (supabaseError.code === 'PGRST000') return true; // Connection error + + // HTTP status codes indicating transient failures + if (supabaseError.status === 429) return true; // Rate limit + if (supabaseError.status === 503) return true; // Service unavailable + if (supabaseError.status === 504) return true; // Gateway timeout + if (supabaseError.status && supabaseError.status >= 500 && supabaseError.status < 600) { + return true; // Server errors (5xx) + } + + // Database-level transient errors + if (supabaseError.code === '40001') return true; // Serialization failure + if (supabaseError.code === '40P01') return true; // Deadlock detected + if (supabaseError.code === '57014') return true; // Query cancelled + if (supabaseError.code === '08000') return true; // Connection exception + if (supabaseError.code === '08003') return true; // Connection does not exist + if (supabaseError.code === '08006') return true; // Connection failure + if (supabaseError.code === '08001') return true; // Unable to connect + if (supabaseError.code === '08004') return true; // Server rejected connection + } + + return false; +} + +/** + * Calculates delay for next retry attempt using exponential backoff + * @param attempt - Current attempt number (0-indexed) + * @param options - Retry configuration + * @returns Delay in milliseconds + */ +function calculateBackoffDelay(attempt: number, options: Required): number { + const exponentialDelay = options.baseDelay * Math.pow(options.backoffMultiplier, attempt); + const cappedDelay = Math.min(exponentialDelay, options.maxDelay); + + // Add jitter (randomness) to prevent thundering herd + if (options.jitter) { + const jitterAmount = cappedDelay * 0.3; // ±30% jitter + const jitterOffset = (Math.random() * 2 - 1) * jitterAmount; + return Math.max(0, cappedDelay + jitterOffset); + } + + return cappedDelay; +} + +/** + * Executes a function with retry logic and exponential backoff + * + * @param fn - Async function to execute + * @param options - Retry configuration options + * @returns Result of the function execution + * @throws Last error if all retry attempts fail + * + * @example + * ```typescript + * const result = await withRetry( + * async () => await supabase.rpc('my_function', { data }), + * { + * maxAttempts: 3, + * onRetry: (attempt, error, delay) => { + * toast.info(`Retrying... (${attempt}/3)`); + * } + * } + * ); + * ``` + */ +export async function withRetry( + fn: () => Promise, + options?: RetryOptions +): Promise { + const config: Required = { + maxAttempts: options?.maxAttempts ?? 3, + baseDelay: options?.baseDelay ?? 1000, + maxDelay: options?.maxDelay ?? 10000, + backoffMultiplier: options?.backoffMultiplier ?? 2, + jitter: options?.jitter ?? true, + onRetry: options?.onRetry ?? (() => {}), + shouldRetry: options?.shouldRetry ?? isRetryableError, + }; + + let lastError: unknown; + + for (let attempt = 0; attempt < config.maxAttempts; attempt++) { + try { + // Execute the function + const result = await fn(); + + // Log successful retry if not first attempt + if (attempt > 0) { + logger.info('Retry succeeded', { + attempt: attempt + 1, + totalAttempts: config.maxAttempts + }); + } + + return result; + } catch (error) { + lastError = error; + + // Check if we should retry + const isLastAttempt = attempt === config.maxAttempts - 1; + const shouldRetry = config.shouldRetry(error); + + if (isLastAttempt || !shouldRetry) { + // Log final failure + logger.error('Retry exhausted or non-retryable error', { + attempt: attempt + 1, + maxAttempts: config.maxAttempts, + isRetryable: shouldRetry, + error: error instanceof Error ? error.message : String(error) + }); + + throw error; + } + + // Calculate delay for next attempt + const delay = calculateBackoffDelay(attempt, config); + + // Log retry attempt + logger.warn('Retrying after error', { + attempt: attempt + 1, + maxAttempts: config.maxAttempts, + delay, + error: error instanceof Error ? error.message : String(error) + }); + + // Invoke callback + config.onRetry(attempt + 1, error, delay); + + // Wait before retrying + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + + // This should never be reached, but TypeScript requires it + throw lastError; +}