From ec5181b9e6eace8dd9cf40a2a47984acf211f45e Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Wed, 5 Nov 2025 13:27:22 +0000 Subject: [PATCH] feat: Implement circuit breaker and retry logic --- src/App.tsx | 2 + src/components/ui/retry-status-indicator.tsx | 75 ++++ src/lib/circuitBreaker.ts | 151 +++++++ src/lib/companyHelpers.ts | 256 ++++++++---- src/lib/entitySubmissionHelpers.ts | 408 +++++++++++-------- src/lib/errorHandler.ts | 1 + src/lib/retryHelpers.ts | 16 +- 7 files changed, 664 insertions(+), 245 deletions(-) create mode 100644 src/components/ui/retry-status-indicator.tsx create mode 100644 src/lib/circuitBreaker.ts diff --git a/src/App.tsx b/src/App.tsx index d8a4a1d9..82170995 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import { AdminErrorBoundary } from "@/components/error/AdminErrorBoundary"; import { EntityErrorBoundary } from "@/components/error/EntityErrorBoundary"; import { breadcrumb } from "@/lib/errorBreadcrumbs"; import { handleError } from "@/lib/errorHandler"; +import { RetryStatusIndicator } from "@/components/ui/retry-status-indicator"; // Core routes (eager-loaded for best UX) import Index from "./pages/Index"; @@ -133,6 +134,7 @@ function AppContent(): React.JSX.Element { +
diff --git a/src/components/ui/retry-status-indicator.tsx b/src/components/ui/retry-status-indicator.tsx new file mode 100644 index 00000000..73a68dd7 --- /dev/null +++ b/src/components/ui/retry-status-indicator.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from 'react'; +import { Loader2 } from 'lucide-react'; +import { Card } from '@/components/ui/card'; +import { Progress } from '@/components/ui/progress'; + +interface RetryStatus { + attempt: number; + maxAttempts: number; + delay: number; + type: string; +} + +/** + * Global retry status indicator + * Shows visual feedback when submissions are being retried due to transient failures + */ +export function RetryStatusIndicator() { + const [retryStatus, setRetryStatus] = useState(null); + const [countdown, setCountdown] = useState(0); + + useEffect(() => { + const handleRetry = (event: Event) => { + const customEvent = event as CustomEvent; + const { attempt, maxAttempts, delay, type } = customEvent.detail; + setRetryStatus({ attempt, maxAttempts, delay, type }); + setCountdown(delay); + }; + + window.addEventListener('submission-retry', handleRetry); + return () => window.removeEventListener('submission-retry', handleRetry); + }, []); + + useEffect(() => { + if (countdown > 0) { + const timer = setInterval(() => { + setCountdown((prev) => { + if (prev <= 100) { + setRetryStatus(null); + return 0; + } + return prev - 100; + }); + }, 100); + return () => clearInterval(timer); + } + }, [countdown]); + + if (!retryStatus) return null; + + const progress = ((retryStatus.delay - countdown) / retryStatus.delay) * 100; + + return ( + +
+ +
+
+

+ Retrying submission... +

+ + {retryStatus.attempt}/{retryStatus.maxAttempts} + +
+ +

+ Network issue detected. Retrying {retryStatus.type} submission in {Math.ceil(countdown / 1000)}s +

+ + +
+
+
+ ); +} diff --git a/src/lib/circuitBreaker.ts b/src/lib/circuitBreaker.ts new file mode 100644 index 00000000..933442e8 --- /dev/null +++ b/src/lib/circuitBreaker.ts @@ -0,0 +1,151 @@ +/** + * Circuit Breaker Pattern Implementation + * + * Prevents cascade failures by temporarily blocking requests when service is degraded. + * Implements three states: CLOSED (normal), OPEN (blocking), HALF_OPEN (testing recovery) + * + * @see https://martinfowler.com/bliki/CircuitBreaker.html + */ + +import { logger } from './logger'; + +export interface CircuitBreakerConfig { + /** Number of failures before opening circuit (default: 5) */ + failureThreshold: number; + /** Milliseconds to wait before testing recovery (default: 60000 = 1 min) */ + resetTimeout: number; + /** Milliseconds window to track failures (default: 120000 = 2 min) */ + monitoringWindow: number; +} + +export enum CircuitState { + CLOSED = 'closed', // Normal operation, requests pass through + OPEN = 'open', // Failures detected, block all requests + HALF_OPEN = 'half_open' // Testing if service recovered +} + +export class CircuitBreaker { + private state: CircuitState = CircuitState.CLOSED; + private failures: number[] = []; // Timestamps of recent failures + private lastFailureTime: number | null = null; + private successCount: number = 0; + private readonly config: Required; + + constructor(config: Partial = {}) { + this.config = { + failureThreshold: config.failureThreshold ?? 5, + resetTimeout: config.resetTimeout ?? 60000, + monitoringWindow: config.monitoringWindow ?? 120000, + }; + } + + /** + * Execute a function through the circuit breaker + * @throws Error if circuit is OPEN (service unavailable) + */ + async execute(fn: () => Promise): Promise { + // If circuit is OPEN, check if we should transition to HALF_OPEN + if (this.state === CircuitState.OPEN) { + const timeSinceFailure = Date.now() - (this.lastFailureTime || 0); + + if (timeSinceFailure > this.config.resetTimeout) { + this.state = CircuitState.HALF_OPEN; + this.successCount = 0; + logger.info('Circuit breaker transitioning to HALF_OPEN', { + timeSinceFailure, + resetTimeout: this.config.resetTimeout + }); + } else { + const timeRemaining = Math.ceil((this.config.resetTimeout - timeSinceFailure) / 1000); + throw new Error( + `Service temporarily unavailable. Our systems detected an outage and are protecting server resources. Please try again in ${timeRemaining} seconds.` + ); + } + } + + try { + const result = await fn(); + this.onSuccess(); + return result; + } catch (error) { + this.onFailure(); + throw error; + } + } + + private onSuccess(): void { + if (this.state === CircuitState.HALF_OPEN) { + this.successCount++; + + // After 2 successful requests, close the circuit + if (this.successCount >= 2) { + this.state = CircuitState.CLOSED; + this.failures = []; + this.lastFailureTime = null; + logger.info('Circuit breaker CLOSED - service recovered', { + successCount: this.successCount + }); + } + } + } + + private onFailure(): void { + const now = Date.now(); + this.lastFailureTime = now; + + // Remove failures outside monitoring window + this.failures = this.failures.filter( + (timestamp) => now - timestamp < this.config.monitoringWindow + ); + + this.failures.push(now); + + // Open circuit if threshold exceeded + if (this.failures.length >= this.config.failureThreshold) { + this.state = CircuitState.OPEN; + logger.error('Circuit breaker OPENED', { + failures: this.failures.length, + threshold: this.config.failureThreshold, + monitoringWindow: this.config.monitoringWindow, + resetTimeout: this.config.resetTimeout + }); + } + } + + /** + * Get current circuit state + */ + getState(): CircuitState { + return this.state; + } + + /** + * Get number of recent failures + */ + getFailureCount(): number { + const now = Date.now(); + return this.failures.filter( + (timestamp) => now - timestamp < this.config.monitoringWindow + ).length; + } + + /** + * Force reset the circuit (testing/debugging only) + */ + reset(): void { + this.state = CircuitState.CLOSED; + this.failures = []; + this.lastFailureTime = null; + this.successCount = 0; + } +} + +/** + * Singleton circuit breaker for Supabase operations + * Shared across all submission flows to detect service-wide outages + */ +export const supabaseCircuitBreaker = new CircuitBreaker({ + failureThreshold: 5, + resetTimeout: 60000, // 1 minute + monitoringWindow: 120000 // 2 minutes +}); diff --git a/src/lib/companyHelpers.ts b/src/lib/companyHelpers.ts index 09ef7477..c58843da 100644 --- a/src/lib/companyHelpers.ts +++ b/src/lib/companyHelpers.ts @@ -3,6 +3,8 @@ import type { Json } from '@/integrations/supabase/types'; import { uploadPendingImages } from './imageUploadHelper'; import { CompanyFormData, TempCompanyData } from '@/types/company'; import { handleError } from './errorHandler'; +import { withRetry } from './retryHelpers'; +import { logger } from './logger'; export type { CompanyFormData, TempCompanyData }; @@ -11,12 +13,18 @@ export async function submitCompanyCreation( companyType: 'manufacturer' | 'designer' | 'operator' | 'property_owner', userId: string ) { - // Check if user is banned - const { data: profile } = await supabase - .from('profiles') - .select('banned') - .eq('user_id', userId) - .single(); + // Check if user is banned (with quick retry for read operation) + const profile = await withRetry( + async () => { + const { data: profile } = await supabase + .from('profiles') + .select('banned') + .eq('user_id', userId) + .single(); + return profile; + }, + { maxAttempts: 2 } + ); if (profile?.banned) { throw new Error('Account suspended. Contact support for assistance.'); @@ -40,46 +48,83 @@ export async function submitCompanyCreation( } } - // Create the main submission record - const { data: submissionData, error: submissionError } = await supabase - .from('content_submissions') - .insert({ - user_id: userId, - submission_type: companyType, - content: { - action: 'create' - }, - status: 'pending' as const - }) - .select('id') - .single(); + // Create submission with retry logic + const result = await withRetry( + async () => { + // Create the main submission record + const { data: submissionData, error: submissionError } = await supabase + .from('content_submissions') + .insert({ + user_id: userId, + submission_type: companyType, + content: { + action: 'create' + }, + status: 'pending' as const + }) + .select('id') + .single(); - if (submissionError) throw submissionError; + if (submissionError) throw submissionError; - // Create the submission item with actual company data - const { error: itemError } = await supabase - .from('submission_items') - .insert({ - submission_id: submissionData.id, - item_type: companyType, - item_data: { - name: data.name, - slug: data.slug, - description: data.description, - person_type: data.person_type, - website_url: data.website_url, - founded_year: data.founded_year, - headquarters_location: data.headquarters_location, - company_type: companyType, - images: processedImages as unknown as Json + // Create the submission item with actual company data + const { error: itemError } = await supabase + .from('submission_items') + .insert({ + submission_id: submissionData.id, + item_type: companyType, + item_data: { + name: data.name, + slug: data.slug, + description: data.description, + person_type: data.person_type, + website_url: data.website_url, + founded_year: data.founded_year, + headquarters_location: data.headquarters_location, + company_type: companyType, + images: processedImages as unknown as Json + }, + status: 'pending' as const, + order_index: 0 + }); + + if (itemError) throw itemError; + + return { submitted: true, submissionId: submissionData.id }; + }, + { + maxAttempts: 3, + onRetry: (attempt, error, delay) => { + logger.warn('Retrying company submission', { attempt, delay, companyType }); + + // Emit event for UI indicator + window.dispatchEvent(new CustomEvent('submission-retry', { + detail: { attempt, maxAttempts: 3, delay, type: companyType } + })); }, - status: 'pending' as const, - order_index: 0 + shouldRetry: (error) => { + // Don't retry validation/business logic 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('slug')) return false; + if (message.includes('permission')) return false; + } + + const { isRetryableError } = require('./retryHelpers'); + return isRetryableError(error); + } + } + ).catch((error) => { + handleError(error, { + action: `${companyType} submission`, + metadata: { retriesExhausted: true }, }); + throw error; + }); - if (itemError) throw itemError; - - return { submitted: true, submissionId: submissionData.id }; + return result; } export async function submitCompanyUpdate( @@ -87,12 +132,18 @@ export async function submitCompanyUpdate( data: CompanyFormData, userId: string ) { - // Check if user is banned - const { data: profile } = await supabase - .from('profiles') - .select('banned') - .eq('user_id', userId) - .single(); + // Check if user is banned (with quick retry for read operation) + const profile = await withRetry( + async () => { + const { data: profile } = await supabase + .from('profiles') + .select('banned') + .eq('user_id', userId) + .single(); + return profile; + }, + { maxAttempts: 2 } + ); if (profile?.banned) { throw new Error('Account suspended. Contact support for assistance.'); @@ -126,46 +177,83 @@ export async function submitCompanyUpdate( } } - // Create the main submission record - const { data: submissionData, error: submissionError } = await supabase - .from('content_submissions') - .insert({ - user_id: userId, - submission_type: existingCompany.company_type, - content: { - action: 'edit', - company_id: companyId - }, - status: 'pending' as const - }) - .select('id') - .single(); + // Create submission with retry logic + const result = await withRetry( + async () => { + // Create the main submission record + const { data: submissionData, error: submissionError } = await supabase + .from('content_submissions') + .insert({ + user_id: userId, + submission_type: existingCompany.company_type, + content: { + action: 'edit', + company_id: companyId + }, + status: 'pending' as const + }) + .select('id') + .single(); - if (submissionError) throw submissionError; + if (submissionError) throw submissionError; - // Create the submission item with actual company data AND original data - const { error: itemError } = await supabase - .from('submission_items') - .insert({ - submission_id: submissionData.id, - item_type: existingCompany.company_type, - item_data: { - company_id: companyId, - name: data.name, - slug: data.slug, - description: data.description, - person_type: data.person_type, - website_url: data.website_url, - founded_year: data.founded_year, - headquarters_location: data.headquarters_location, - images: processedImages as unknown as Json + // Create the submission item with actual company data AND original data + const { error: itemError } = await supabase + .from('submission_items') + .insert({ + submission_id: submissionData.id, + item_type: existingCompany.company_type, + item_data: { + company_id: companyId, + name: data.name, + slug: data.slug, + description: data.description, + person_type: data.person_type, + website_url: data.website_url, + founded_year: data.founded_year, + headquarters_location: data.headquarters_location, + images: processedImages as unknown as Json + }, + original_data: JSON.parse(JSON.stringify(existingCompany)), + status: 'pending' as const, + order_index: 0 + }); + + if (itemError) throw itemError; + + return { submitted: true, submissionId: submissionData.id }; + }, + { + maxAttempts: 3, + onRetry: (attempt, error, delay) => { + logger.warn('Retrying company update', { attempt, delay, companyId }); + + // Emit event for UI indicator + window.dispatchEvent(new CustomEvent('submission-retry', { + detail: { attempt, maxAttempts: 3, delay, type: `${existingCompany.company_type} update` } + })); }, - original_data: JSON.parse(JSON.stringify(existingCompany)), - status: 'pending' as const, - order_index: 0 + shouldRetry: (error) => { + // Don't retry validation/business logic 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('slug')) return false; + if (message.includes('permission')) return false; + } + + const { isRetryableError } = require('./retryHelpers'); + return isRetryableError(error); + } + } + ).catch((error) => { + handleError(error, { + action: `${existingCompany.company_type} update`, + metadata: { retriesExhausted: true, companyId }, }); + throw error; + }); - if (itemError) throw itemError; - - return { submitted: true, submissionId: submissionData.id }; + return result; } diff --git a/src/lib/entitySubmissionHelpers.ts b/src/lib/entitySubmissionHelpers.ts index 2eb66d15..af545b5a 100644 --- a/src/lib/entitySubmissionHelpers.ts +++ b/src/lib/entitySubmissionHelpers.ts @@ -530,19 +530,27 @@ export async function submitParkCreation( } } - // Standard single-entity creation - // Check if user is banned - const { data: profile } = await supabase - .from('profiles') - .select('banned') - .eq('user_id', userId) - .single(); + // Standard single-entity creation with retry logic + const { withRetry } = await import('./retryHelpers'); + + // Check if user is banned (with quick retry for read operation) + const profile = await withRetry( + async () => { + const { data: profile } = await supabase + .from('profiles') + .select('banned') + .eq('user_id', userId) + .single(); + return profile; + }, + { maxAttempts: 2 } + ); if (profile?.banned) { throw new Error('Account suspended. Contact support for assistance.'); } - // Upload any pending local images first + // Upload any pending local images first (no retry - handled internally) let processedImages = data.images; if (data.images?.uploaded && data.images.uploaded.length > 0) { try { @@ -559,73 +567,110 @@ export async function submitParkCreation( } } - // Create the main submission record - const { data: submissionData, error: submissionError } = await supabase - .from('content_submissions') - .insert({ - user_id: userId, - submission_type: 'park', - content: { - action: 'create' + // Create submission with retry logic + const result = await withRetry( + async () => { + // Create the main submission record + const { data: submissionData, error: submissionError } = await supabase + .from('content_submissions') + .insert({ + user_id: userId, + submission_type: 'park', + content: { + action: 'create' + }, + status: 'pending' as const + }) + .select('id') + .single(); + + if (submissionError) throw submissionError; + + // Get image URLs/IDs from processed images using assignments + const uploadedImages = processedImages?.uploaded || []; + const bannerIndex = processedImages?.banner_assignment; + const cardIndex = processedImages?.card_assignment; + + const bannerImage = (bannerIndex !== null && bannerIndex !== undefined) ? uploadedImages[bannerIndex] : null; + const cardImage = (cardIndex !== null && cardIndex !== undefined) ? uploadedImages[cardIndex] : null; + + // Insert into relational park_submissions table + const { data: parkSubmission, error: parkSubmissionError } = await supabase + .from('park_submissions' as any) + .insert({ + submission_id: submissionData.id, + name: data.name, + slug: data.slug, + description: data.description || null, + park_type: data.park_type, + status: data.status, + opening_date: data.opening_date ? new Date(data.opening_date).toISOString().split('T')[0] : null, + closing_date: data.closing_date ? new Date(data.closing_date).toISOString().split('T')[0] : null, + website_url: data.website_url || null, + phone: data.phone || null, + email: data.email || null, + operator_id: data.operator_id || null, + property_owner_id: data.property_owner_id || null, + location_id: data.location_id || null, + banner_image_url: bannerImage?.url || data.banner_image_url || null, + banner_image_id: bannerImage?.cloudflare_id || data.banner_image_id || null, + card_image_url: cardImage?.url || data.card_image_url || null, + card_image_id: cardImage?.cloudflare_id || data.card_image_id || null + } as any) + .select('id') + .single(); + + if (parkSubmissionError) throw parkSubmissionError; + + // Create submission_items record linking to park_submissions + const { error: itemError } = await supabase + .from('submission_items') + .insert({ + submission_id: submissionData.id, + item_type: 'park', + action_type: 'create', + park_submission_id: (parkSubmission as any).id, + status: 'pending' as const, + order_index: 0 + } as any); + + if (itemError) throw itemError; + + return { submitted: true, submissionId: submissionData.id }; + }, + { + maxAttempts: 3, + onRetry: (attempt, error, delay) => { + logger.warn('Retrying park submission', { attempt, delay }); + + // Emit event for UI indicator + window.dispatchEvent(new CustomEvent('submission-retry', { + detail: { attempt, maxAttempts: 3, delay, type: 'park' } + })); }, - status: 'pending' as const - }) - .select('id') - .single(); + shouldRetry: (error) => { + // Don't retry validation/business logic 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('slug')) return false; + if (message.includes('permission')) return false; + } + + const { isRetryableError } = require('./retryHelpers'); + return isRetryableError(error); + } + } + ).catch((error) => { + handleError(error, { + action: 'Park submission', + metadata: { retriesExhausted: true }, + }); + throw error; + }); - if (submissionError) throw submissionError; - - // Get image URLs/IDs from processed images using assignments - const uploadedImages = processedImages?.uploaded || []; - const bannerIndex = processedImages?.banner_assignment; - const cardIndex = processedImages?.card_assignment; - - const bannerImage = (bannerIndex !== null && bannerIndex !== undefined) ? uploadedImages[bannerIndex] : null; - const cardImage = (cardIndex !== null && cardIndex !== undefined) ? uploadedImages[cardIndex] : null; - - // Insert into relational park_submissions table - const { data: parkSubmission, error: parkSubmissionError } = await supabase - .from('park_submissions' as any) - .insert({ - submission_id: submissionData.id, - name: data.name, - slug: data.slug, - description: data.description || null, - park_type: data.park_type, - status: data.status, - opening_date: data.opening_date ? new Date(data.opening_date).toISOString().split('T')[0] : null, - closing_date: data.closing_date ? new Date(data.closing_date).toISOString().split('T')[0] : null, - website_url: data.website_url || null, - phone: data.phone || null, - email: data.email || null, - operator_id: data.operator_id || null, - property_owner_id: data.property_owner_id || null, - location_id: data.location_id || null, - banner_image_url: bannerImage?.url || data.banner_image_url || null, - banner_image_id: bannerImage?.cloudflare_id || data.banner_image_id || null, - card_image_url: cardImage?.url || data.card_image_url || null, - card_image_id: cardImage?.cloudflare_id || data.card_image_id || null - } as any) - .select('id') - .single(); - - if (parkSubmissionError) throw parkSubmissionError; - - // Create submission_items record linking to park_submissions - const { error: itemError } = await supabase - .from('submission_items') - .insert({ - submission_id: submissionData.id, - item_type: 'park', - action_type: 'create', - park_submission_id: (parkSubmission as any).id, - status: 'pending' as const, - order_index: 0 - } as any); - - if (itemError) throw itemError; - - return { submitted: true, submissionId: submissionData.id }; + return result; } /** @@ -847,19 +892,27 @@ export async function submitRideCreation( } } - // Standard single-entity creation - // Check if user is banned - const { data: profile } = await supabase - .from('profiles') - .select('banned') - .eq('user_id', userId) - .single(); + // Standard single-entity creation with retry logic + const { withRetry } = await import('./retryHelpers'); + + // Check if user is banned (with quick retry for read operation) + const profile = await withRetry( + async () => { + const { data: profile } = await supabase + .from('profiles') + .select('banned') + .eq('user_id', userId) + .single(); + return profile; + }, + { maxAttempts: 2 } + ); if (profile?.banned) { throw new Error('Account suspended. Contact support for assistance.'); } - // Upload any pending local images first + // Upload any pending local images first (no retry - handled internally) let processedImages = data.images; if (data.images?.uploaded && data.images.uploaded.length > 0) { try { @@ -876,86 +929,123 @@ export async function submitRideCreation( } } - // Create the main submission record - const { data: submissionData, error: submissionError } = await supabase - .from('content_submissions') - .insert({ - user_id: userId, - submission_type: 'ride', - content: { - action: 'create' + // Create submission with retry logic + const result = await withRetry( + async () => { + // Create the main submission record + const { data: submissionData, error: submissionError } = await supabase + .from('content_submissions') + .insert({ + user_id: userId, + submission_type: 'ride', + content: { + action: 'create' + }, + status: 'pending' as const + }) + .select('id') + .single(); + + if (submissionError) throw submissionError; + + // Get image URLs/IDs from processed images using assignments + const uploadedImages = processedImages?.uploaded || []; + const bannerIndex = processedImages?.banner_assignment; + const cardIndex = processedImages?.card_assignment; + + const bannerImage = (bannerIndex !== null && bannerIndex !== undefined) ? uploadedImages[bannerIndex] : null; + const cardImage = (cardIndex !== null && cardIndex !== undefined) ? uploadedImages[cardIndex] : null; + + // Insert into relational ride_submissions table + const { data: rideSubmission, error: rideSubmissionError } = await supabase + .from('ride_submissions' as any) + .insert({ + submission_id: submissionData.id, + park_id: data.park_id || null, + name: data.name, + slug: data.slug, + description: data.description || null, + category: data.category, + ride_sub_type: data.ride_sub_type || null, + status: data.status, + opening_date: data.opening_date ? new Date(data.opening_date).toISOString().split('T')[0] : null, + closing_date: data.closing_date ? new Date(data.closing_date).toISOString().split('T')[0] : null, + manufacturer_id: data.manufacturer_id || null, + designer_id: data.designer_id || null, + ride_model_id: data.ride_model_id || null, + height_requirement: data.height_requirement || null, + age_requirement: data.age_requirement || null, + capacity_per_hour: data.capacity_per_hour || null, + duration_seconds: data.duration_seconds || null, + max_speed_kmh: data.max_speed_kmh || null, + max_height_meters: data.max_height_meters || null, + length_meters: data.length_meters || null, + drop_height_meters: data.drop_height_meters || null, + inversions: data.inversions || 0, + max_g_force: data.max_g_force || null, + coaster_type: data.coaster_type || null, + seating_type: data.seating_type || null, + intensity_level: data.intensity_level || null, + banner_image_url: bannerImage?.url || data.banner_image_url || null, + banner_image_id: bannerImage?.cloudflare_id || data.banner_image_id || null, + card_image_url: cardImage?.url || data.card_image_url || null, + card_image_id: cardImage?.cloudflare_id || data.card_image_id || null, + image_url: null + } as any) + .select('id') + .single(); + + if (rideSubmissionError) throw rideSubmissionError; + + // Create submission_items record linking to ride_submissions + const { error: itemError } = await supabase + .from('submission_items') + .insert({ + submission_id: submissionData.id, + item_type: 'ride', + action_type: 'create', + ride_submission_id: (rideSubmission as any).id, + status: 'pending' as const, + order_index: 0 + } as any); + + if (itemError) throw itemError; + + return { submitted: true, submissionId: submissionData.id }; + }, + { + maxAttempts: 3, + onRetry: (attempt, error, delay) => { + logger.warn('Retrying ride submission', { attempt, delay }); + + // Emit event for UI indicator + window.dispatchEvent(new CustomEvent('submission-retry', { + detail: { attempt, maxAttempts: 3, delay, type: 'ride' } + })); }, - status: 'pending' as const - }) - .select('id') - .single(); + shouldRetry: (error) => { + // Don't retry validation/business logic 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('slug')) return false; + if (message.includes('permission')) return false; + } + + const { isRetryableError } = require('./retryHelpers'); + return isRetryableError(error); + } + } + ).catch((error) => { + handleError(error, { + action: 'Ride submission', + metadata: { retriesExhausted: true }, + }); + throw error; + }); - if (submissionError) throw submissionError; - - // ✅ FIXED: Get image URLs/IDs from processed images using assignments - const uploadedImages = processedImages?.uploaded || []; - const bannerIndex = processedImages?.banner_assignment; - const cardIndex = processedImages?.card_assignment; - - const bannerImage = (bannerIndex !== null && bannerIndex !== undefined) ? uploadedImages[bannerIndex] : null; - const cardImage = (cardIndex !== null && cardIndex !== undefined) ? uploadedImages[cardIndex] : null; - - // Insert into relational ride_submissions table - const { data: rideSubmission, error: rideSubmissionError } = await supabase - .from('ride_submissions' as any) - .insert({ - submission_id: submissionData.id, - park_id: data.park_id || null, - name: data.name, - slug: data.slug, - description: data.description || null, - category: data.category, - ride_sub_type: data.ride_sub_type || null, - status: data.status, - opening_date: data.opening_date ? new Date(data.opening_date).toISOString().split('T')[0] : null, - closing_date: data.closing_date ? new Date(data.closing_date).toISOString().split('T')[0] : null, - manufacturer_id: data.manufacturer_id || null, - designer_id: data.designer_id || null, - ride_model_id: data.ride_model_id || null, - height_requirement: data.height_requirement || null, - age_requirement: data.age_requirement || null, - capacity_per_hour: data.capacity_per_hour || null, - duration_seconds: data.duration_seconds || null, - max_speed_kmh: data.max_speed_kmh || null, - max_height_meters: data.max_height_meters || null, - length_meters: data.length_meters || null, - drop_height_meters: data.drop_height_meters || null, - inversions: data.inversions || 0, - max_g_force: data.max_g_force || null, - coaster_type: data.coaster_type || null, - seating_type: data.seating_type || null, - intensity_level: data.intensity_level || null, - banner_image_url: bannerImage?.url || data.banner_image_url || null, - banner_image_id: bannerImage?.cloudflare_id || data.banner_image_id || null, - card_image_url: cardImage?.url || data.card_image_url || null, - card_image_id: cardImage?.cloudflare_id || data.card_image_id || null, - image_url: null - } as any) - .select('id') - .single(); - - if (rideSubmissionError) throw rideSubmissionError; - - // Create submission_items record linking to ride_submissions - const { error: itemError } = await supabase - .from('submission_items') - .insert({ - submission_id: submissionData.id, - item_type: 'ride', - action_type: 'create', - ride_submission_id: (rideSubmission as any).id, - status: 'pending' as const, - order_index: 0 - } as any); - - if (itemError) throw itemError; - - return { submitted: true, submissionId: submissionData.id }; + return result; } /** diff --git a/src/lib/errorHandler.ts b/src/lib/errorHandler.ts index da06be26..8154c46a 100644 --- a/src/lib/errorHandler.ts +++ b/src/lib/errorHandler.ts @@ -118,6 +118,7 @@ export const handleError = ( isRetry: context.metadata?.isRetry || false, attempt: context.metadata?.attempt, retriesExhausted: context.metadata?.retriesExhausted || false, + circuitBreakerState: context.metadata?.circuitState, }), p_timezone: envContext.timezone, p_referrer: document.referrer || undefined, diff --git a/src/lib/retryHelpers.ts b/src/lib/retryHelpers.ts index d44c0980..c9c616e7 100644 --- a/src/lib/retryHelpers.ts +++ b/src/lib/retryHelpers.ts @@ -4,6 +4,7 @@ */ import { logger } from './logger'; +import { supabaseCircuitBreaker } from './circuitBreaker'; export interface RetryOptions { /** Maximum number of attempts (default: 3) */ @@ -135,8 +136,10 @@ export async function withRetry( for (let attempt = 0; attempt < config.maxAttempts; attempt++) { try { - // Execute the function - const result = await fn(); + // Execute the function through circuit breaker + const result = await supabaseCircuitBreaker.execute(async () => { + return await fn(); + }); // Log successful retry if not first attempt if (attempt > 0) { @@ -150,6 +153,15 @@ export async function withRetry( } catch (error) { lastError = error; + // Check if circuit breaker blocked the request + if (error instanceof Error && error.message.includes('Circuit breaker is OPEN')) { + logger.error('Circuit breaker prevented retry', { + attempt: attempt + 1, + circuitState: supabaseCircuitBreaker.getState() + }); + throw error; // Don't retry if circuit is open + } + // Check if we should retry const isLastAttempt = attempt === config.maxAttempts - 1; const shouldRetry = config.shouldRetry(error);