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

@@ -17,6 +17,7 @@ import { AdminErrorBoundary } from "@/components/error/AdminErrorBoundary";
import { EntityErrorBoundary } from "@/components/error/EntityErrorBoundary"; import { EntityErrorBoundary } from "@/components/error/EntityErrorBoundary";
import { breadcrumb } from "@/lib/errorBreadcrumbs"; import { breadcrumb } from "@/lib/errorBreadcrumbs";
import { handleError } from "@/lib/errorHandler"; import { handleError } from "@/lib/errorHandler";
import { RetryStatusIndicator } from "@/components/ui/retry-status-indicator";
// Core routes (eager-loaded for best UX) // Core routes (eager-loaded for best UX)
import Index from "./pages/Index"; import Index from "./pages/Index";
@@ -133,6 +134,7 @@ function AppContent(): React.JSX.Element {
<TooltipProvider> <TooltipProvider>
<NavigationTracker /> <NavigationTracker />
<LocationAutoDetectProvider /> <LocationAutoDetectProvider />
<RetryStatusIndicator />
<Toaster /> <Toaster />
<Sonner /> <Sonner />
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">

View File

@@ -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<RetryStatus | null>(null);
const [countdown, setCountdown] = useState(0);
useEffect(() => {
const handleRetry = (event: Event) => {
const customEvent = event as CustomEvent<RetryStatus>;
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 (
<Card className="fixed bottom-4 right-4 z-50 p-4 shadow-lg border-amber-500 bg-amber-50 dark:bg-amber-950 w-80 animate-in slide-in-from-bottom-4">
<div className="flex items-start gap-3">
<Loader2 className="w-5 h-5 animate-spin text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
Retrying submission...
</p>
<span className="text-xs font-mono text-amber-700 dark:text-amber-300">
{retryStatus.attempt}/{retryStatus.maxAttempts}
</span>
</div>
<p className="text-xs text-amber-700 dark:text-amber-300">
Network issue detected. Retrying {retryStatus.type} submission in {Math.ceil(countdown / 1000)}s
</p>
<Progress value={progress} className="h-1" />
</div>
</div>
</Card>
);
}

151
src/lib/circuitBreaker.ts Normal file
View File

@@ -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<CircuitBreakerConfig>;
constructor(config: Partial<CircuitBreakerConfig> = {}) {
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<T>(fn: () => Promise<T>): Promise<T> {
// 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
});

View File

@@ -3,6 +3,8 @@ import type { Json } from '@/integrations/supabase/types';
import { uploadPendingImages } from './imageUploadHelper'; import { uploadPendingImages } from './imageUploadHelper';
import { CompanyFormData, TempCompanyData } from '@/types/company'; import { CompanyFormData, TempCompanyData } from '@/types/company';
import { handleError } from './errorHandler'; import { handleError } from './errorHandler';
import { withRetry } from './retryHelpers';
import { logger } from './logger';
export type { CompanyFormData, TempCompanyData }; export type { CompanyFormData, TempCompanyData };
@@ -11,12 +13,18 @@ export async function submitCompanyCreation(
companyType: 'manufacturer' | 'designer' | 'operator' | 'property_owner', companyType: 'manufacturer' | 'designer' | 'operator' | 'property_owner',
userId: string userId: string
) { ) {
// Check if user is banned // Check if user is banned (with quick retry for read operation)
const profile = await withRetry(
async () => {
const { data: profile } = await supabase const { data: profile } = await supabase
.from('profiles') .from('profiles')
.select('banned') .select('banned')
.eq('user_id', userId) .eq('user_id', userId)
.single(); .single();
return profile;
},
{ maxAttempts: 2 }
);
if (profile?.banned) { if (profile?.banned) {
throw new Error('Account suspended. Contact support for assistance.'); throw new Error('Account suspended. Contact support for assistance.');
@@ -40,6 +48,9 @@ export async function submitCompanyCreation(
} }
} }
// Create submission with retry logic
const result = await withRetry(
async () => {
// Create the main submission record // Create the main submission record
const { data: submissionData, error: submissionError } = await supabase const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions') .from('content_submissions')
@@ -80,6 +91,40 @@ export async function submitCompanyCreation(
if (itemError) throw itemError; if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id }; 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 }
}));
},
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;
});
return result;
} }
export async function submitCompanyUpdate( export async function submitCompanyUpdate(
@@ -87,12 +132,18 @@ export async function submitCompanyUpdate(
data: CompanyFormData, data: CompanyFormData,
userId: string userId: string
) { ) {
// Check if user is banned // Check if user is banned (with quick retry for read operation)
const profile = await withRetry(
async () => {
const { data: profile } = await supabase const { data: profile } = await supabase
.from('profiles') .from('profiles')
.select('banned') .select('banned')
.eq('user_id', userId) .eq('user_id', userId)
.single(); .single();
return profile;
},
{ maxAttempts: 2 }
);
if (profile?.banned) { if (profile?.banned) {
throw new Error('Account suspended. Contact support for assistance.'); throw new Error('Account suspended. Contact support for assistance.');
@@ -126,6 +177,9 @@ export async function submitCompanyUpdate(
} }
} }
// Create submission with retry logic
const result = await withRetry(
async () => {
// Create the main submission record // Create the main submission record
const { data: submissionData, error: submissionError } = await supabase const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions') .from('content_submissions')
@@ -168,4 +222,38 @@ export async function submitCompanyUpdate(
if (itemError) throw itemError; if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id }; 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` }
}));
},
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;
});
return result;
} }

View File

@@ -530,19 +530,27 @@ export async function submitParkCreation(
} }
} }
// Standard single-entity creation // Standard single-entity creation with retry logic
// Check if user is banned 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 const { data: profile } = await supabase
.from('profiles') .from('profiles')
.select('banned') .select('banned')
.eq('user_id', userId) .eq('user_id', userId)
.single(); .single();
return profile;
},
{ maxAttempts: 2 }
);
if (profile?.banned) { if (profile?.banned) {
throw new Error('Account suspended. Contact support for assistance.'); 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; let processedImages = data.images;
if (data.images?.uploaded && data.images.uploaded.length > 0) { if (data.images?.uploaded && data.images.uploaded.length > 0) {
try { try {
@@ -559,6 +567,9 @@ export async function submitParkCreation(
} }
} }
// Create submission with retry logic
const result = await withRetry(
async () => {
// Create the main submission record // Create the main submission record
const { data: submissionData, error: submissionError } = await supabase const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions') .from('content_submissions')
@@ -626,6 +637,40 @@ export async function submitParkCreation(
if (itemError) throw itemError; if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id }; 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' }
}));
},
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;
});
return result;
} }
/** /**
@@ -847,19 +892,27 @@ export async function submitRideCreation(
} }
} }
// Standard single-entity creation // Standard single-entity creation with retry logic
// Check if user is banned 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 const { data: profile } = await supabase
.from('profiles') .from('profiles')
.select('banned') .select('banned')
.eq('user_id', userId) .eq('user_id', userId)
.single(); .single();
return profile;
},
{ maxAttempts: 2 }
);
if (profile?.banned) { if (profile?.banned) {
throw new Error('Account suspended. Contact support for assistance.'); 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; let processedImages = data.images;
if (data.images?.uploaded && data.images.uploaded.length > 0) { if (data.images?.uploaded && data.images.uploaded.length > 0) {
try { try {
@@ -876,6 +929,9 @@ export async function submitRideCreation(
} }
} }
// Create submission with retry logic
const result = await withRetry(
async () => {
// Create the main submission record // Create the main submission record
const { data: submissionData, error: submissionError } = await supabase const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions') .from('content_submissions')
@@ -892,7 +948,7 @@ export async function submitRideCreation(
if (submissionError) throw submissionError; if (submissionError) throw submissionError;
// ✅ FIXED: Get image URLs/IDs from processed images using assignments // Get image URLs/IDs from processed images using assignments
const uploadedImages = processedImages?.uploaded || []; const uploadedImages = processedImages?.uploaded || [];
const bannerIndex = processedImages?.banner_assignment; const bannerIndex = processedImages?.banner_assignment;
const cardIndex = processedImages?.card_assignment; const cardIndex = processedImages?.card_assignment;
@@ -956,6 +1012,40 @@ export async function submitRideCreation(
if (itemError) throw itemError; if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id }; 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' }
}));
},
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;
});
return result;
} }
/** /**

View File

@@ -118,6 +118,7 @@ export const handleError = (
isRetry: context.metadata?.isRetry || false, isRetry: context.metadata?.isRetry || false,
attempt: context.metadata?.attempt, attempt: context.metadata?.attempt,
retriesExhausted: context.metadata?.retriesExhausted || false, retriesExhausted: context.metadata?.retriesExhausted || false,
circuitBreakerState: context.metadata?.circuitState,
}), }),
p_timezone: envContext.timezone, p_timezone: envContext.timezone,
p_referrer: document.referrer || undefined, p_referrer: document.referrer || undefined,

View File

@@ -4,6 +4,7 @@
*/ */
import { logger } from './logger'; import { logger } from './logger';
import { supabaseCircuitBreaker } from './circuitBreaker';
export interface RetryOptions { export interface RetryOptions {
/** Maximum number of attempts (default: 3) */ /** Maximum number of attempts (default: 3) */
@@ -135,8 +136,10 @@ export async function withRetry<T>(
for (let attempt = 0; attempt < config.maxAttempts; attempt++) { for (let attempt = 0; attempt < config.maxAttempts; attempt++) {
try { try {
// Execute the function // Execute the function through circuit breaker
const result = await fn(); const result = await supabaseCircuitBreaker.execute(async () => {
return await fn();
});
// Log successful retry if not first attempt // Log successful retry if not first attempt
if (attempt > 0) { if (attempt > 0) {
@@ -150,6 +153,15 @@ export async function withRetry<T>(
} catch (error) { } catch (error) {
lastError = 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 // Check if we should retry
const isLastAttempt = attempt === config.maxAttempts - 1; const isLastAttempt = attempt === config.maxAttempts - 1;
const shouldRetry = config.shouldRetry(error); const shouldRetry = config.shouldRetry(error);