feat: Implement circuit breaker and retry logic

This commit is contained in:
gpt-engineer-app[bot]
2025-11-05 13:27:22 +00:00
parent 5e0640252c
commit ec5181b9e6
7 changed files with 664 additions and 245 deletions

View File

@@ -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;
}
/**