Compare commits

..

2 Commits

Author SHA1 Message Date
gpt-engineer-app[bot]
5e0640252c feat: Implement retry logic for composite submissions 2025-11-05 13:16:30 +00:00
gpt-engineer-app[bot]
876119c079 Fix composite submission error handling 2025-11-05 13:09:54 +00:00
3 changed files with 361 additions and 26 deletions

View File

@@ -347,21 +347,121 @@ async function submitCompositeCreation(
depends_on: null // Will be set by RPC based on refs depends_on: null // Will be set by RPC based on refs
}); });
// Use RPC to create submission with items atomically // Pre-validation to catch issues early with actionable error messages
const { data: result, error } = await supabase.rpc('create_submission_with_items', { if (uploadedPrimary.type === 'park') {
p_user_id: userId, if (!primaryData.name) throw new Error('Park name is required');
p_submission_type: uploadedPrimary.type, if (!primaryData.slug) throw new Error('Park slug is required');
p_content: { action: 'create' } as unknown as Json, if (!primaryData.park_type) throw new Error('Park type is required');
p_items: submissionItems as unknown as Json[] if (!primaryData.status) throw new Error('Park status is required');
}); } else if (uploadedPrimary.type === 'ride') {
if (!primaryData.name) throw new Error('Ride name is required');
if (!primaryData.slug) throw new Error('Ride slug is required');
if (!primaryData.status) throw new Error('Ride status is required');
}
if (error) { // Validate dependencies
for (const dep of uploadedDependencies) {
if (dep.type === 'company') {
if (!dep.data.name) throw new Error(`${dep.companyType || 'Company'} name is required`);
if (!dep.data.slug) throw new Error(`${dep.companyType || 'Company'} slug is required`);
if (!dep.data.company_type && !dep.companyType) {
throw new Error('Company type is required');
}
}
}
// 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}` : ''}`
);
// 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, { handleError(error, {
action: 'Composite submission', action: 'Composite submission',
metadata: { primaryType: uploadedPrimary.type, dependencyCount: dependencies.length }, metadata: {
primaryType: uploadedPrimary.type,
dependencyCount: dependencies.length,
supabaseCode: (error as any).supabaseCode,
supabaseDetails: (error as any).supabaseDetails,
supabaseHint: (error as any).supabaseHint,
retriesExhausted: true
},
}); });
throw new Error(`Failed to create composite submission: ${error instanceof Error ? error.message : 'Unknown error'}`);
} throw error;
});
return { submitted: true, submissionId: result }; return { submitted: true, submissionId: result };
} }

View File

@@ -30,23 +30,60 @@ export const handleError = (
const errorId = (context.metadata?.requestId as string) || crypto.randomUUID(); const errorId = (context.metadata?.requestId as string) || crypto.randomUUID();
const shortErrorId = errorId.slice(0, 8); const shortErrorId = errorId.slice(0, 8);
const errorMessage = error instanceof AppError // Enhanced error message and stack extraction
? error.userMessage || error.message let errorMessage: string;
: error instanceof Error let stack: string | undefined;
? error.message let errorName = 'UnknownError';
: 'An unexpected error occurred';
if (error instanceof Error) {
errorMessage = error instanceof AppError
? error.userMessage || error.message
: error.message;
stack = error.stack;
errorName = error.name;
} else if (error && typeof error === 'object') {
// Handle Supabase errors (plain objects with message/code/details)
const supabaseError = error as {
message?: string;
code?: string;
details?: string;
hint?: string;
};
errorMessage = supabaseError.message || 'An unexpected error occurred';
errorName = 'SupabaseError';
// Create synthetic stack trace for Supabase errors to aid debugging
if (supabaseError.code || supabaseError.details || supabaseError.hint) {
const stackParts = [
`SupabaseError: ${errorMessage}`,
supabaseError.code ? ` Code: ${supabaseError.code}` : null,
supabaseError.details ? ` Details: ${supabaseError.details}` : null,
supabaseError.hint ? ` Hint: ${supabaseError.hint}` : null,
` at ${context.action}`,
` Reference ID: ${errorId}`
].filter(Boolean);
stack = stackParts.join('\n');
}
} else if (typeof error === 'string') {
errorMessage = error;
} else {
errorMessage = 'An unexpected error occurred';
}
// Log to console/monitoring with enhanced debugging // Log to console/monitoring with enhanced debugging
const stack = error instanceof Error ? error.stack : undefined;
logger.error('Error occurred', { logger.error('Error occurred', {
...context, ...context,
error: error instanceof Error ? error.message : String(error), error: errorMessage,
stack, stack,
errorId, errorId,
errorName,
errorType: typeof error, errorType: typeof error,
errorConstructor: error?.constructor?.name, errorConstructor: error?.constructor?.name,
hasStack: !!stack, hasStack: !!stack,
isSyntheticStack: !!(error && typeof error === 'object' && !(error instanceof Error) && stack),
}); });
// Additional debug logging when stack is missing // Additional debug logging when stack is missing
@@ -72,11 +109,16 @@ export const handleError = (
p_endpoint: context.action, p_endpoint: context.action,
p_method: 'ERROR', p_method: 'ERROR',
p_status_code: 500, p_status_code: 500,
p_error_type: error instanceof Error ? error.name : 'UnknownError', p_error_type: errorName,
p_error_message: errorMessage, p_error_message: errorMessage,
p_error_stack: error instanceof Error ? error.stack : undefined, 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,
@@ -89,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
View 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;
}