mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 17:31:12 -05:00
feat: Implement retry logic for composite submissions
This commit is contained in:
@@ -370,40 +370,98 @@ async function submitCompositeCreation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use RPC to create submission with items atomically
|
// Use RPC to create submission with items atomically with retry logic
|
||||||
const { data: result, error } = await supabase.rpc('create_submission_with_items', {
|
const { withRetry } = await import('./retryHelpers');
|
||||||
p_user_id: userId,
|
const { toast } = await import('@/hooks/use-toast');
|
||||||
p_submission_type: uploadedPrimary.type,
|
|
||||||
p_content: { action: 'create' } as unknown as Json,
|
const result = await withRetry(
|
||||||
p_items: submissionItems as unknown as Json[]
|
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) {
|
if (error) {
|
||||||
// Extract Supabase error details for better error logging
|
// Extract Supabase error details for better error logging
|
||||||
const supabaseError = error as { message?: string; code?: string; details?: string; hint?: string };
|
const supabaseError = error as { message?: string; code?: string; details?: string; hint?: string };
|
||||||
const errorMessage = supabaseError.message || 'Unknown error';
|
const errorMessage = supabaseError.message || 'Unknown error';
|
||||||
const errorCode = supabaseError.code;
|
const errorCode = supabaseError.code;
|
||||||
const errorDetails = supabaseError.details;
|
const errorDetails = supabaseError.details;
|
||||||
const errorHint = supabaseError.hint;
|
const errorHint = supabaseError.hint;
|
||||||
|
|
||||||
// Create proper Error instance with enhanced context
|
// Create proper Error instance with enhanced context
|
||||||
const enhancedError = new Error(
|
const enhancedError = new Error(
|
||||||
`Composite submission failed: ${errorMessage}${errorDetails ? `\nDetails: ${errorDetails}` : ''}${errorHint ? `\nHint: ${errorHint}` : ''}`
|
`Composite submission failed: ${errorMessage}${errorDetails ? `\nDetails: ${errorDetails}` : ''}${errorHint ? `\nHint: ${errorHint}` : ''}`
|
||||||
);
|
);
|
||||||
|
|
||||||
handleError(enhancedError, {
|
// 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',
|
action: 'Composite submission',
|
||||||
metadata: {
|
metadata: {
|
||||||
primaryType: uploadedPrimary.type,
|
primaryType: uploadedPrimary.type,
|
||||||
dependencyCount: dependencies.length,
|
dependencyCount: dependencies.length,
|
||||||
supabaseCode: errorCode,
|
supabaseCode: (error as any).supabaseCode,
|
||||||
supabaseDetails: errorDetails,
|
supabaseDetails: (error as any).supabaseDetails,
|
||||||
supabaseHint: errorHint
|
supabaseHint: (error as any).supabaseHint,
|
||||||
|
retriesExhausted: true
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
throw enhancedError;
|
throw error;
|
||||||
}
|
});
|
||||||
|
|
||||||
return { submitted: true, submissionId: result };
|
return { submitted: true, submissionId: result };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,12 @@ export const handleError = (
|
|||||||
p_error_message: errorMessage,
|
p_error_message: errorMessage,
|
||||||
p_error_stack: stack,
|
p_error_stack: stack,
|
||||||
p_user_agent: navigator.userAgent,
|
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_timezone: envContext.timezone,
|
||||||
p_referrer: document.referrer || undefined,
|
p_referrer: document.referrer || undefined,
|
||||||
p_duration_ms: context.duration,
|
p_duration_ms: context.duration,
|
||||||
@@ -126,11 +131,14 @@ export const handleError = (
|
|||||||
logger.error('Failed to capture error context', { logError });
|
logger.error('Failed to capture error context', { logError });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show user-friendly toast with error ID
|
// Show user-friendly toast with error ID (skip for retry attempts)
|
||||||
toast.error(context.action, {
|
const isRetry = context.metadata?.isRetry === true || context.metadata?.attempt;
|
||||||
description: `${errorMessage}\n\nReference ID: ${shortErrorId}`,
|
if (!isRetry) {
|
||||||
duration: 5000,
|
toast.error(context.action, {
|
||||||
});
|
description: `${errorMessage}\n\nReference ID: ${shortErrorId}`,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return errorId;
|
return errorId;
|
||||||
};
|
};
|
||||||
|
|||||||
190
src/lib/retryHelpers.ts
Normal file
190
src/lib/retryHelpers.ts
Normal file
@@ -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<RetryOptions>): 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<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
options?: RetryOptions
|
||||||
|
): Promise<T> {
|
||||||
|
const config: Required<RetryOptions> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user