Compare commits

...

2 Commits

Author SHA1 Message Date
gpt-engineer-app[bot]
73e847015d Add rate limit aware retries
Enhance retry logic to detect 429 rate limits, parse Retry-After headers, and apply smart backoff across all entity submissions. Adds rate-limit-aware backoff, preserves user feedback via UI events, and ensures retries respect server-provided guidance.
2025-11-10 19:05:31 +00:00
gpt-engineer-app[bot]
8ed5edbe24 Implement automatic retry in entitySubmissionHelpers.ts
Add exponential backoff retry logic with user feedback
- Integrate with existing withRetry patterns
- Introduce unique retry IDs for submissions
- Emit consistent UI events for retry progress, success, and failure
- Enhance rate-limit handling including Retry-After scenarios
- Standardize baseDelay and shouldRetry across park, ride, company, ride_model, and other submissions
2025-11-10 19:02:11 +00:00
3 changed files with 582 additions and 50 deletions

View File

@@ -12,6 +12,8 @@ interface RetryStatus {
type: string; type: string;
state: 'retrying' | 'success' | 'failed'; state: 'retrying' | 'success' | 'failed';
errorId?: string; errorId?: string;
isRateLimit?: boolean;
retryAfter?: number;
} }
/** /**
@@ -24,12 +26,22 @@ export function RetryStatusIndicator() {
useEffect(() => { useEffect(() => {
const handleRetry = (event: Event) => { const handleRetry = (event: Event) => {
const customEvent = event as CustomEvent<Omit<RetryStatus, 'state'>>; const customEvent = event as CustomEvent<Omit<RetryStatus, 'state' | 'countdown'>>;
const { id, attempt, maxAttempts, delay, type } = customEvent.detail; const { id, attempt, maxAttempts, delay, type, isRateLimit, retryAfter } = customEvent.detail;
setRetries(prev => { setRetries(prev => {
const next = new Map(prev); const next = new Map(prev);
next.set(id, { id, attempt, maxAttempts, delay, type, state: 'retrying', countdown: delay }); next.set(id, {
id,
attempt,
maxAttempts,
delay,
type,
state: 'retrying',
countdown: delay,
isRateLimit,
retryAfter
});
return next; return next;
}); });
}; };
@@ -161,6 +173,17 @@ function RetryCard({ retry }: { retry: RetryStatus & { countdown: number } }) {
// Retrying state // Retrying state
const progress = retry.delay > 0 ? ((retry.delay - retry.countdown) / retry.delay) * 100 : 0; const progress = retry.delay > 0 ? ((retry.delay - retry.countdown) / retry.delay) * 100 : 0;
// Customize message based on rate limit status
const getMessage = () => {
if (retry.isRateLimit) {
if (retry.retryAfter) {
return `Rate limit reached. Waiting ${Math.ceil(retry.countdown / 1000)}s as requested by server...`;
}
return `Rate limit reached. Using smart backoff - retrying in ${Math.ceil(retry.countdown / 1000)}s...`;
}
return `Network issue detected. Retrying ${retry.type} submission in ${Math.ceil(retry.countdown / 1000)}s`;
};
return ( return (
<Card className="p-4 shadow-lg border-amber-500 bg-amber-50 dark:bg-amber-950 w-80 animate-in slide-in-from-bottom-4"> <Card className="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"> <div className="flex items-start gap-3">
@@ -168,7 +191,7 @@ function RetryCard({ retry }: { retry: RetryStatus & { countdown: number } }) {
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm font-medium text-amber-900 dark:text-amber-100"> <p className="text-sm font-medium text-amber-900 dark:text-amber-100">
Retrying submission... {retry.isRateLimit ? 'Rate Limited' : 'Retrying submission...'}
</p> </p>
<span className="text-xs font-mono text-amber-700 dark:text-amber-300"> <span className="text-xs font-mono text-amber-700 dark:text-amber-300">
{retry.attempt}/{retry.maxAttempts} {retry.attempt}/{retry.maxAttempts}
@@ -176,7 +199,7 @@ function RetryCard({ retry }: { retry: RetryStatus & { countdown: number } }) {
</div> </div>
<p className="text-xs text-amber-700 dark:text-amber-300"> <p className="text-xs text-amber-700 dark:text-amber-300">
Network issue detected. Retrying {retry.type} submission in {Math.ceil(retry.countdown / 1000)}s {getMessage()}
</p> </p>
<Progress value={progress} className="h-1" /> <Progress value={progress} className="h-1" />

View File

@@ -9,7 +9,7 @@ import { logger } from './logger';
import { handleError } from './errorHandler'; import { handleError } from './errorHandler';
import type { TimelineEventFormData, EntityType } from '@/types/timeline'; import type { TimelineEventFormData, EntityType } from '@/types/timeline';
import { breadcrumb } from './errorBreadcrumbs'; import { breadcrumb } from './errorBreadcrumbs';
import { isRetryableError } from './retryHelpers'; import { isRetryableError, isRateLimitError, extractRetryAfter } from './retryHelpers';
import { import {
validateParkCreateFields, validateParkCreateFields,
validateRideCreateFields, validateRideCreateFields,
@@ -773,6 +773,8 @@ export async function submitParkCreation(
} }
// Create submission with retry logic // Create submission with retry logic
const retryId = crypto.randomUUID();
const result = await withRetry( const result = await withRetry(
async () => { async () => {
// Create the main submission record // Create the main submission record
@@ -882,12 +884,30 @@ export async function submitParkCreation(
}, },
{ {
maxAttempts: 3, maxAttempts: 3,
baseDelay: 1000,
onRetry: (attempt, error, delay) => { onRetry: (attempt, error, delay) => {
logger.warn('Retrying park submission', { attempt, delay }); const isRateLimit = isRateLimitError(error);
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
// Emit event for UI indicator logger.warn('Retrying park submission', {
attempt,
delay,
isRateLimit,
retryAfter,
error: error instanceof Error ? error.message : String(error)
});
// Emit event for UI indicator with rate limit info
window.dispatchEvent(new CustomEvent('submission-retry', { window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { attempt, maxAttempts: 3, delay, type: 'park' } detail: {
id: retryId,
attempt,
maxAttempts: 3,
delay,
type: 'park',
isRateLimit,
retryAfter
}
})); }));
}, },
shouldRetry: (error) => { shouldRetry: (error) => {
@@ -896,18 +916,35 @@ export async function submitParkCreation(
const message = error.message.toLowerCase(); const message = error.message.toLowerCase();
if (message.includes('required')) return false; if (message.includes('required')) return false;
if (message.includes('banned')) return false; if (message.includes('banned')) return false;
if (message.includes('suspended')) return false;
if (message.includes('slug')) 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('permission')) return false;
if (message.includes('forbidden')) return false;
if (message.includes('unauthorized')) return false;
} }
return isRetryableError(error); return isRetryableError(error);
} }
} }
).catch((error) => { ).then((data) => {
handleError(error, { // Emit success event
window.dispatchEvent(new CustomEvent('submission-retry-success', {
detail: { id: retryId }
}));
return data;
}).catch((error) => {
const errorId = handleError(error, {
action: 'Park submission', action: 'Park submission',
metadata: { retriesExhausted: true }, metadata: { retriesExhausted: true },
}); });
// Emit failure event
window.dispatchEvent(new CustomEvent('submission-retry-failed', {
detail: { id: retryId, errorId }
}));
throw error; throw error;
}); });
@@ -1103,17 +1140,31 @@ export async function submitParkUpdate(
}, },
{ {
maxAttempts: 3, maxAttempts: 3,
baseDelay: 1000,
onRetry: (attempt, error, delay) => { onRetry: (attempt, error, delay) => {
const isRateLimit = isRateLimitError(error);
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
logger.warn('Retrying park update submission', { logger.warn('Retrying park update submission', {
attempt, attempt,
delay, delay,
parkId, parkId,
isRateLimit,
retryAfter,
error: error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error)
}); });
// Emit event for UI retry indicator // Emit event for UI retry indicator with rate limit info
window.dispatchEvent(new CustomEvent('submission-retry', { window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'park update' } detail: {
id: retryId,
attempt,
maxAttempts: 3,
delay,
type: 'park update',
isRateLimit,
retryAfter
}
})); }));
}, },
shouldRetry: (error) => { shouldRetry: (error) => {
@@ -1506,12 +1557,30 @@ export async function submitRideCreation(
}, },
{ {
maxAttempts: 3, maxAttempts: 3,
baseDelay: 1000,
onRetry: (attempt, error, delay) => { onRetry: (attempt, error, delay) => {
logger.warn('Retrying ride submission', { attempt, delay }); const isRateLimit = isRateLimitError(error);
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
// Emit event for UI indicator logger.warn('Retrying ride submission', {
attempt,
delay,
isRateLimit,
retryAfter,
error: error instanceof Error ? error.message : String(error)
});
// Emit event for UI indicator with rate limit info
window.dispatchEvent(new CustomEvent('submission-retry', { window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'ride' } detail: {
id: retryId,
attempt,
maxAttempts: 3,
delay,
type: 'ride',
isRateLimit,
retryAfter
}
})); }));
}, },
shouldRetry: (error) => { shouldRetry: (error) => {
@@ -1520,8 +1589,13 @@ export async function submitRideCreation(
const message = error.message.toLowerCase(); const message = error.message.toLowerCase();
if (message.includes('required')) return false; if (message.includes('required')) return false;
if (message.includes('banned')) return false; if (message.includes('banned')) return false;
if (message.includes('suspended')) return false;
if (message.includes('slug')) 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('permission')) return false;
if (message.includes('forbidden')) return false;
if (message.includes('unauthorized')) return false;
} }
return isRetryableError(error); return isRetryableError(error);
@@ -1714,17 +1788,31 @@ export async function submitRideUpdate(
}, },
{ {
maxAttempts: 3, maxAttempts: 3,
baseDelay: 1000,
onRetry: (attempt, error, delay) => { onRetry: (attempt, error, delay) => {
const isRateLimit = isRateLimitError(error);
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
logger.warn('Retrying ride update submission', { logger.warn('Retrying ride update submission', {
attempt, attempt,
delay, delay,
rideId, rideId,
isRateLimit,
retryAfter,
error: error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error)
}); });
// Emit event for UI retry indicator // Emit event for UI retry indicator with rate limit info
window.dispatchEvent(new CustomEvent('submission-retry', { window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'ride update' } detail: {
id: retryId,
attempt,
maxAttempts: 3,
delay,
type: 'ride update',
isRateLimit,
retryAfter
}
})); }));
}, },
shouldRetry: (error) => { shouldRetry: (error) => {
@@ -1733,8 +1821,13 @@ export async function submitRideUpdate(
const message = error.message.toLowerCase(); const message = error.message.toLowerCase();
if (message.includes('required')) return false; if (message.includes('required')) return false;
if (message.includes('banned')) return false; if (message.includes('banned')) return false;
if (message.includes('suspended')) return false;
if (message.includes('slug')) 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('permission')) return false;
if (message.includes('forbidden')) return false;
if (message.includes('unauthorized')) return false;
if (message.includes('not found')) return false; if (message.includes('not found')) return false;
if (message.includes('not allowed')) return false; if (message.includes('not allowed')) return false;
} }
@@ -1838,6 +1931,8 @@ export async function submitRideModelCreation(
// Submit with retry logic // Submit with retry logic
breadcrumb.apiCall('content_submissions', 'INSERT'); breadcrumb.apiCall('content_submissions', 'INSERT');
const retryId = crypto.randomUUID();
const result = await withRetry( const result = await withRetry(
async () => { async () => {
// Create the main submission record // Create the main submission record
@@ -1925,10 +2020,28 @@ export async function submitRideModelCreation(
}, },
{ {
maxAttempts: 3, maxAttempts: 3,
baseDelay: 1000,
onRetry: (attempt, error, delay) => { onRetry: (attempt, error, delay) => {
logger.warn('Retrying ride model submission', { attempt, delay }); const isRateLimit = isRateLimitError(error);
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
logger.warn('Retrying ride model submission', {
attempt,
delay,
isRateLimit,
retryAfter,
error: error instanceof Error ? error.message : String(error)
});
window.dispatchEvent(new CustomEvent('submission-retry', { window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { attempt, maxAttempts: 3, delay, type: 'ride_model' } detail: {
id: retryId,
attempt,
maxAttempts: 3,
delay,
type: 'ride_model',
isRateLimit,
retryAfter
}
})); }));
}, },
shouldRetry: (error) => { shouldRetry: (error) => {
@@ -1936,12 +2049,36 @@ export async function submitRideModelCreation(
const message = error.message.toLowerCase(); const message = error.message.toLowerCase();
if (message.includes('required')) return false; if (message.includes('required')) return false;
if (message.includes('banned')) return false; if (message.includes('banned')) return false;
if (message.includes('suspended')) return false;
if (message.includes('slug')) 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;
} }
return isRetryableError(error); return isRetryableError(error);
} }
} }
); ).then((data) => {
// Emit success event
window.dispatchEvent(new CustomEvent('submission-retry-success', {
detail: { id: retryId }
}));
return data;
}).catch((error) => {
const errorId = handleError(error, {
action: 'Ride model submission',
metadata: { retriesExhausted: true },
});
// Emit failure event
window.dispatchEvent(new CustomEvent('submission-retry-failed', {
detail: { id: retryId, errorId }
}));
throw error;
});
return result; return result;
} }
@@ -2006,6 +2143,8 @@ export async function submitRideModelUpdate(
// Submit with retry logic // Submit with retry logic
breadcrumb.apiCall('content_submissions', 'INSERT'); breadcrumb.apiCall('content_submissions', 'INSERT');
const retryId = crypto.randomUUID();
const result = await withRetry( const result = await withRetry(
async () => { async () => {
// Create the main submission record // Create the main submission record
@@ -2091,10 +2230,28 @@ export async function submitRideModelUpdate(
}, },
{ {
maxAttempts: 3, maxAttempts: 3,
baseDelay: 1000,
onRetry: (attempt, error, delay) => { onRetry: (attempt, error, delay) => {
logger.warn('Retrying ride model update', { attempt, delay }); const isRateLimit = isRateLimitError(error);
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
logger.warn('Retrying ride model update', {
attempt,
delay,
isRateLimit,
retryAfter,
error: error instanceof Error ? error.message : String(error)
});
window.dispatchEvent(new CustomEvent('submission-retry', { window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { attempt, maxAttempts: 3, delay, type: 'ride_model_update' } detail: {
id: retryId,
attempt,
maxAttempts: 3,
delay,
type: 'ride_model_update',
isRateLimit,
retryAfter
}
})); }));
}, },
shouldRetry: (error) => { shouldRetry: (error) => {
@@ -2102,12 +2259,34 @@ export async function submitRideModelUpdate(
const message = error.message.toLowerCase(); const message = error.message.toLowerCase();
if (message.includes('required')) return false; if (message.includes('required')) return false;
if (message.includes('banned')) return false; if (message.includes('banned')) return false;
if (message.includes('suspended')) return false;
if (message.includes('slug')) 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;
} }
return isRetryableError(error); return isRetryableError(error);
} }
} }
); ).then((data) => {
window.dispatchEvent(new CustomEvent('submission-retry-success', {
detail: { id: retryId }
}));
return data;
}).catch((error) => {
const errorId = handleError(error, {
action: 'Ride model update submission',
metadata: { retriesExhausted: true },
});
window.dispatchEvent(new CustomEvent('submission-retry-failed', {
detail: { id: retryId, errorId }
}));
throw error;
});
return result; return result;
} }
@@ -2170,6 +2349,8 @@ export async function submitManufacturerCreation(
// Submit with retry logic // Submit with retry logic
breadcrumb.apiCall('content_submissions', 'INSERT'); breadcrumb.apiCall('content_submissions', 'INSERT');
const retryId = crypto.randomUUID();
const result = await withRetry( const result = await withRetry(
async () => { async () => {
const { data: submissionData, error: submissionError } = await supabase const { data: submissionData, error: submissionError } = await supabase
@@ -2209,10 +2390,28 @@ export async function submitManufacturerCreation(
}, },
{ {
maxAttempts: 3, maxAttempts: 3,
baseDelay: 1000,
onRetry: (attempt, error, delay) => { onRetry: (attempt, error, delay) => {
logger.warn('Retrying manufacturer submission', { attempt, delay }); const isRateLimit = isRateLimitError(error);
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
logger.warn('Retrying manufacturer submission', {
attempt,
delay,
isRateLimit,
retryAfter,
error: error instanceof Error ? error.message : String(error)
});
window.dispatchEvent(new CustomEvent('submission-retry', { window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { attempt, maxAttempts: 3, delay, type: 'manufacturer' } detail: {
id: retryId,
attempt,
maxAttempts: 3,
delay,
type: 'manufacturer',
isRateLimit,
retryAfter
}
})); }));
}, },
shouldRetry: (error) => { shouldRetry: (error) => {
@@ -2220,12 +2419,34 @@ export async function submitManufacturerCreation(
const message = error.message.toLowerCase(); const message = error.message.toLowerCase();
if (message.includes('required')) return false; if (message.includes('required')) return false;
if (message.includes('banned')) return false; if (message.includes('banned')) return false;
if (message.includes('suspended')) return false;
if (message.includes('slug')) 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;
} }
return isRetryableError(error); return isRetryableError(error);
} }
} }
); ).then((data) => {
window.dispatchEvent(new CustomEvent('submission-retry-success', {
detail: { id: retryId }
}));
return data;
}).catch((error) => {
const errorId = handleError(error, {
action: 'Manufacturer submission',
metadata: { retriesExhausted: true },
});
window.dispatchEvent(new CustomEvent('submission-retry-failed', {
detail: { id: retryId, errorId }
}));
throw error;
});
return result; return result;
} }
@@ -2283,6 +2504,8 @@ export async function submitManufacturerUpdate(
// Submit with retry logic // Submit with retry logic
breadcrumb.apiCall('content_submissions', 'INSERT'); breadcrumb.apiCall('content_submissions', 'INSERT');
const retryId = crypto.randomUUID();
const result = await withRetry( const result = await withRetry(
async () => { async () => {
const { data: submissionData, error: submissionError } = await supabase const { data: submissionData, error: submissionError } = await supabase
@@ -2320,10 +2543,28 @@ export async function submitManufacturerUpdate(
}, },
{ {
maxAttempts: 3, maxAttempts: 3,
baseDelay: 1000,
onRetry: (attempt, error, delay) => { onRetry: (attempt, error, delay) => {
logger.warn('Retrying manufacturer update', { attempt, delay }); const isRateLimit = isRateLimitError(error);
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
logger.warn('Retrying manufacturer update', {
attempt,
delay,
isRateLimit,
retryAfter,
error: error instanceof Error ? error.message : String(error)
});
window.dispatchEvent(new CustomEvent('submission-retry', { window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { attempt, maxAttempts: 3, delay, type: 'manufacturer_update' } detail: {
id: retryId,
attempt,
maxAttempts: 3,
delay,
type: 'manufacturer_update',
isRateLimit,
retryAfter
}
})); }));
}, },
shouldRetry: (error) => { shouldRetry: (error) => {
@@ -2394,6 +2635,8 @@ export async function submitDesignerCreation(
// Submit with retry logic // Submit with retry logic
breadcrumb.apiCall('content_submissions', 'INSERT'); breadcrumb.apiCall('content_submissions', 'INSERT');
const retryId = crypto.randomUUID();
const result = await withRetry( const result = await withRetry(
async () => { async () => {
const { data: submissionData, error: submissionError } = await supabase const { data: submissionData, error: submissionError } = await supabase
@@ -2433,10 +2676,28 @@ export async function submitDesignerCreation(
}, },
{ {
maxAttempts: 3, maxAttempts: 3,
baseDelay: 1000,
onRetry: (attempt, error, delay) => { onRetry: (attempt, error, delay) => {
logger.warn('Retrying designer submission', { attempt, delay }); const isRateLimit = isRateLimitError(error);
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
logger.warn('Retrying designer submission', {
attempt,
delay,
isRateLimit,
retryAfter,
error: error instanceof Error ? error.message : String(error)
});
window.dispatchEvent(new CustomEvent('submission-retry', { window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { attempt, maxAttempts: 3, delay, type: 'designer' } detail: {
id: retryId,
attempt,
maxAttempts: 3,
delay,
type: 'designer',
isRateLimit,
retryAfter
}
})); }));
}, },
shouldRetry: (error) => { shouldRetry: (error) => {
@@ -2507,6 +2768,8 @@ export async function submitDesignerUpdate(
// Submit with retry logic // Submit with retry logic
breadcrumb.apiCall('content_submissions', 'INSERT'); breadcrumb.apiCall('content_submissions', 'INSERT');
const retryId = crypto.randomUUID();
const result = await withRetry( const result = await withRetry(
async () => { async () => {
const { data: submissionData, error: submissionError } = await supabase const { data: submissionData, error: submissionError } = await supabase
@@ -2544,10 +2807,28 @@ export async function submitDesignerUpdate(
}, },
{ {
maxAttempts: 3, maxAttempts: 3,
baseDelay: 1000,
onRetry: (attempt, error, delay) => { onRetry: (attempt, error, delay) => {
logger.warn('Retrying designer update', { attempt, delay }); const isRateLimit = isRateLimitError(error);
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
logger.warn('Retrying designer update', {
attempt,
delay,
isRateLimit,
retryAfter,
error: error instanceof Error ? error.message : String(error)
});
window.dispatchEvent(new CustomEvent('submission-retry', { window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { attempt, maxAttempts: 3, delay, type: 'designer_update' } detail: {
id: retryId,
attempt,
maxAttempts: 3,
delay,
type: 'designer_update',
isRateLimit,
retryAfter
}
})); }));
}, },
shouldRetry: (error) => { shouldRetry: (error) => {
@@ -2618,6 +2899,8 @@ export async function submitOperatorCreation(
// Submit with retry logic // Submit with retry logic
breadcrumb.apiCall('content_submissions', 'INSERT'); breadcrumb.apiCall('content_submissions', 'INSERT');
const retryId = crypto.randomUUID();
const result = await withRetry( const result = await withRetry(
async () => { async () => {
const { data: submissionData, error: submissionError } = await supabase const { data: submissionData, error: submissionError } = await supabase
@@ -2657,10 +2940,15 @@ export async function submitOperatorCreation(
}, },
{ {
maxAttempts: 3, maxAttempts: 3,
baseDelay: 1000,
onRetry: (attempt, error, delay) => { onRetry: (attempt, error, delay) => {
logger.warn('Retrying operator submission', { attempt, delay }); logger.warn('Retrying operator submission', {
attempt,
delay,
error: error instanceof Error ? error.message : String(error)
});
window.dispatchEvent(new CustomEvent('submission-retry', { window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { attempt, maxAttempts: 3, delay, type: 'operator' } detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'operator' }
})); }));
}, },
shouldRetry: (error) => { shouldRetry: (error) => {
@@ -2668,12 +2956,34 @@ export async function submitOperatorCreation(
const message = error.message.toLowerCase(); const message = error.message.toLowerCase();
if (message.includes('required')) return false; if (message.includes('required')) return false;
if (message.includes('banned')) return false; if (message.includes('banned')) return false;
if (message.includes('suspended')) return false;
if (message.includes('slug')) 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;
} }
return isRetryableError(error); return isRetryableError(error);
} }
} }
); ).then((data) => {
window.dispatchEvent(new CustomEvent('submission-retry-success', {
detail: { id: retryId }
}));
return data;
}).catch((error) => {
const errorId = handleError(error, {
action: 'Operator submission',
metadata: { retriesExhausted: true },
});
window.dispatchEvent(new CustomEvent('submission-retry-failed', {
detail: { id: retryId, errorId }
}));
throw error;
});
return result; return result;
} }
@@ -2731,6 +3041,8 @@ export async function submitOperatorUpdate(
// Submit with retry logic // Submit with retry logic
breadcrumb.apiCall('content_submissions', 'INSERT'); breadcrumb.apiCall('content_submissions', 'INSERT');
const retryId = crypto.randomUUID();
const result = await withRetry( const result = await withRetry(
async () => { async () => {
const { data: submissionData, error: submissionError } = await supabase const { data: submissionData, error: submissionError } = await supabase
@@ -2768,10 +3080,28 @@ export async function submitOperatorUpdate(
}, },
{ {
maxAttempts: 3, maxAttempts: 3,
baseDelay: 1000,
onRetry: (attempt, error, delay) => { onRetry: (attempt, error, delay) => {
logger.warn('Retrying operator update', { attempt, delay }); const isRateLimit = isRateLimitError(error);
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
logger.warn('Retrying operator update', {
attempt,
delay,
isRateLimit,
retryAfter,
error: error instanceof Error ? error.message : String(error)
});
window.dispatchEvent(new CustomEvent('submission-retry', { window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { attempt, maxAttempts: 3, delay, type: 'operator_update' } detail: {
id: retryId,
attempt,
maxAttempts: 3,
delay,
type: 'operator_update',
isRateLimit,
retryAfter
}
})); }));
}, },
shouldRetry: (error) => { shouldRetry: (error) => {
@@ -2842,6 +3172,8 @@ export async function submitPropertyOwnerCreation(
// Submit with retry logic // Submit with retry logic
breadcrumb.apiCall('content_submissions', 'INSERT'); breadcrumb.apiCall('content_submissions', 'INSERT');
const retryId = crypto.randomUUID();
const result = await withRetry( const result = await withRetry(
async () => { async () => {
const { data: submissionData, error: submissionError } = await supabase const { data: submissionData, error: submissionError } = await supabase
@@ -2881,10 +3213,15 @@ export async function submitPropertyOwnerCreation(
}, },
{ {
maxAttempts: 3, maxAttempts: 3,
baseDelay: 1000,
onRetry: (attempt, error, delay) => { onRetry: (attempt, error, delay) => {
logger.warn('Retrying property owner submission', { attempt, delay }); logger.warn('Retrying property owner submission', {
attempt,
delay,
error: error instanceof Error ? error.message : String(error)
});
window.dispatchEvent(new CustomEvent('submission-retry', { window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { attempt, maxAttempts: 3, delay, type: 'property_owner' } detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'property_owner' }
})); }));
}, },
shouldRetry: (error) => { shouldRetry: (error) => {
@@ -2892,12 +3229,34 @@ export async function submitPropertyOwnerCreation(
const message = error.message.toLowerCase(); const message = error.message.toLowerCase();
if (message.includes('required')) return false; if (message.includes('required')) return false;
if (message.includes('banned')) return false; if (message.includes('banned')) return false;
if (message.includes('suspended')) return false;
if (message.includes('slug')) 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;
} }
return isRetryableError(error); return isRetryableError(error);
} }
} }
); ).then((data) => {
window.dispatchEvent(new CustomEvent('submission-retry-success', {
detail: { id: retryId }
}));
return data;
}).catch((error) => {
const errorId = handleError(error, {
action: 'Property owner submission',
metadata: { retriesExhausted: true },
});
window.dispatchEvent(new CustomEvent('submission-retry-failed', {
detail: { id: retryId, errorId }
}));
throw error;
});
return result; return result;
} }
@@ -2955,6 +3314,8 @@ export async function submitPropertyOwnerUpdate(
// Submit with retry logic // Submit with retry logic
breadcrumb.apiCall('content_submissions', 'INSERT'); breadcrumb.apiCall('content_submissions', 'INSERT');
const retryId = crypto.randomUUID();
const result = await withRetry( const result = await withRetry(
async () => { async () => {
const { data: submissionData, error: submissionError } = await supabase const { data: submissionData, error: submissionError } = await supabase
@@ -2992,10 +3353,28 @@ export async function submitPropertyOwnerUpdate(
}, },
{ {
maxAttempts: 3, maxAttempts: 3,
baseDelay: 1000,
onRetry: (attempt, error, delay) => { onRetry: (attempt, error, delay) => {
logger.warn('Retrying property owner update', { attempt, delay }); const isRateLimit = isRateLimitError(error);
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
logger.warn('Retrying property owner update', {
attempt,
delay,
isRateLimit,
retryAfter,
error: error instanceof Error ? error.message : String(error)
});
window.dispatchEvent(new CustomEvent('submission-retry', { window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { attempt, maxAttempts: 3, delay, type: 'property_owner_update' } detail: {
id: retryId,
attempt,
maxAttempts: 3,
delay,
type: 'property_owner_update',
isRateLimit,
retryAfter
}
})); }));
}, },
shouldRetry: (error) => { shouldRetry: (error) => {

View File

@@ -23,6 +23,97 @@ export interface RetryOptions {
shouldRetry?: (error: unknown) => boolean; shouldRetry?: (error: unknown) => boolean;
} }
/**
* Extract Retry-After value from error headers
* @param error - The error object
* @returns Delay in milliseconds, or null if not found
*/
export function extractRetryAfter(error: unknown): number | null {
if (!error || typeof error !== 'object') return null;
// Check for Retry-After in error object
const errorWithHeaders = error as { headers?: Headers | Record<string, string>; retryAfter?: number | string };
// Direct retryAfter property
if (errorWithHeaders.retryAfter) {
const retryAfter = errorWithHeaders.retryAfter;
if (typeof retryAfter === 'number') {
return retryAfter * 1000; // Convert seconds to milliseconds
}
if (typeof retryAfter === 'string') {
// Try parsing as number first (delay-seconds)
const seconds = parseInt(retryAfter, 10);
if (!isNaN(seconds)) {
return seconds * 1000;
}
// Try parsing as HTTP-date
const date = new Date(retryAfter);
if (!isNaN(date.getTime())) {
const delay = date.getTime() - Date.now();
return Math.max(0, delay);
}
}
}
// Check headers object
if (errorWithHeaders.headers) {
let retryAfterValue: string | null = null;
if (errorWithHeaders.headers instanceof Headers) {
retryAfterValue = errorWithHeaders.headers.get('retry-after');
} else if (typeof errorWithHeaders.headers === 'object') {
// Check both lowercase and capitalized versions
retryAfterValue = errorWithHeaders.headers['retry-after']
|| errorWithHeaders.headers['Retry-After']
|| null;
}
if (retryAfterValue) {
// Try parsing as number first (delay-seconds)
const seconds = parseInt(retryAfterValue, 10);
if (!isNaN(seconds)) {
return seconds * 1000;
}
// Try parsing as HTTP-date
const date = new Date(retryAfterValue);
if (!isNaN(date.getTime())) {
const delay = date.getTime() - Date.now();
return Math.max(0, delay);
}
}
}
return null;
}
/**
* Check if error is a rate limit (429) error
* @param error - The error to check
* @returns true if error is a rate limit error
*/
export function isRateLimitError(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
const errorWithStatus = error as { status?: number; code?: string };
// HTTP 429 status
if (errorWithStatus.status === 429) return true;
// Check error message for rate limit indicators
if (error instanceof Error) {
const message = error.message.toLowerCase();
if (message.includes('rate limit') ||
message.includes('too many requests') ||
message.includes('quota exceeded')) {
return true;
}
}
return false;
}
/** /**
* Determines if an error is transient and retryable * Determines if an error is transient and retryable
* @param error - The error to check * @param error - The error to check
@@ -56,7 +147,7 @@ export function isRetryableError(error: unknown): boolean {
if (supabaseError.code === 'PGRST000') return true; // Connection error if (supabaseError.code === 'PGRST000') return true; // Connection error
// HTTP status codes indicating transient failures // HTTP status codes indicating transient failures
if (supabaseError.status === 429) return true; // Rate limit if (supabaseError.status === 429) return true; // Rate limit - ALWAYS retry
if (supabaseError.status === 503) return true; // Service unavailable if (supabaseError.status === 503) return true; // Service unavailable
if (supabaseError.status === 504) return true; // Gateway timeout if (supabaseError.status === 504) return true; // Gateway timeout
if (supabaseError.status && supabaseError.status >= 500 && supabaseError.status < 600) { if (supabaseError.status && supabaseError.status >= 500 && supabaseError.status < 600) {
@@ -78,12 +169,46 @@ export function isRetryableError(error: unknown): boolean {
} }
/** /**
* Calculates delay for next retry attempt using exponential backoff * Calculates delay for next retry attempt using exponential backoff or Retry-After header
* @param attempt - Current attempt number (0-indexed) * @param attempt - Current attempt number (0-indexed)
* @param options - Retry configuration * @param options - Retry configuration
* @param error - The error that triggered the retry (to check for Retry-After)
* @returns Delay in milliseconds * @returns Delay in milliseconds
*/ */
function calculateBackoffDelay(attempt: number, options: Required<RetryOptions>): number { function calculateBackoffDelay(
attempt: number,
options: Required<RetryOptions>,
error?: unknown
): number {
// Check for rate limit with Retry-After header
if (error && isRateLimitError(error)) {
const retryAfter = extractRetryAfter(error);
if (retryAfter !== null) {
// Respect the Retry-After header, but cap it at maxDelay
const cappedRetryAfter = Math.min(retryAfter, options.maxDelay);
logger.info('[Retry] Rate limit detected - respecting Retry-After header', {
retryAfterMs: retryAfter,
cappedMs: cappedRetryAfter,
attempt
});
return cappedRetryAfter;
}
// No Retry-After header but is rate limit - use aggressive backoff
const rateLimitDelay = options.baseDelay * Math.pow(options.backoffMultiplier, attempt + 1);
const cappedDelay = Math.min(rateLimitDelay, options.maxDelay);
logger.info('[Retry] Rate limit detected - using aggressive backoff', {
delayMs: cappedDelay,
attempt
});
return cappedDelay;
}
// Standard exponential backoff
const exponentialDelay = options.baseDelay * Math.pow(options.backoffMultiplier, attempt); const exponentialDelay = options.baseDelay * Math.pow(options.backoffMultiplier, attempt);
const cappedDelay = Math.min(exponentialDelay, options.maxDelay); const cappedDelay = Math.min(exponentialDelay, options.maxDelay);
@@ -246,18 +371,23 @@ export async function withRetry<T>(
throw error; throw error;
} }
// Calculate delay for next attempt // Calculate delay for next attempt (respects Retry-After for rate limits)
const delay = calculateBackoffDelay(attempt, config); const delay = calculateBackoffDelay(attempt, config, error);
// Log retry attempt with rate limit detection
const isRateLimit = isRateLimitError(error);
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
// Log retry attempt
logger.warn('Retrying after error', { logger.warn('Retrying after error', {
attempt: attempt + 1, attempt: attempt + 1,
maxAttempts: config.maxAttempts, maxAttempts: config.maxAttempts,
delay, delay,
isRateLimit,
retryAfterMs: retryAfter,
error: error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error)
}); });
// Invoke callback // Invoke callback with additional context
config.onRetry(attempt + 1, error, delay); config.onRetry(attempt + 1, error, delay);
// Wait before retrying // Wait before retrying