mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:31:12 -05:00
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.
This commit is contained in:
@@ -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" />
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -886,11 +886,28 @@ export async function submitParkCreation(
|
|||||||
maxAttempts: 3,
|
maxAttempts: 3,
|
||||||
baseDelay: 1000,
|
baseDelay: 1000,
|
||||||
onRetry: (attempt, error, delay) => {
|
onRetry: (attempt, error, delay) => {
|
||||||
logger.warn('Retrying park submission', { attempt, delay, error: error instanceof Error ? error.message : String(error) });
|
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: { id: retryId, attempt, maxAttempts: 3, delay, type: 'park' }
|
detail: {
|
||||||
|
id: retryId,
|
||||||
|
attempt,
|
||||||
|
maxAttempts: 3,
|
||||||
|
delay,
|
||||||
|
type: 'park',
|
||||||
|
isRateLimit,
|
||||||
|
retryAfter
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
shouldRetry: (error) => {
|
shouldRetry: (error) => {
|
||||||
@@ -1125,16 +1142,29 @@ export async function submitParkUpdate(
|
|||||||
maxAttempts: 3,
|
maxAttempts: 3,
|
||||||
baseDelay: 1000,
|
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) => {
|
||||||
@@ -1529,15 +1559,28 @@ export async function submitRideCreation(
|
|||||||
maxAttempts: 3,
|
maxAttempts: 3,
|
||||||
baseDelay: 1000,
|
baseDelay: 1000,
|
||||||
onRetry: (attempt, error, delay) => {
|
onRetry: (attempt, error, delay) => {
|
||||||
|
const isRateLimit = isRateLimitError(error);
|
||||||
|
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
|
||||||
|
|
||||||
logger.warn('Retrying ride submission', {
|
logger.warn('Retrying ride submission', {
|
||||||
attempt,
|
attempt,
|
||||||
delay,
|
delay,
|
||||||
|
isRateLimit,
|
||||||
|
retryAfter,
|
||||||
error: error instanceof Error ? error.message : String(error)
|
error: error instanceof Error ? error.message : String(error)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Emit event for UI indicator
|
// 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) => {
|
||||||
@@ -1747,16 +1790,29 @@ export async function submitRideUpdate(
|
|||||||
maxAttempts: 3,
|
maxAttempts: 3,
|
||||||
baseDelay: 1000,
|
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) => {
|
||||||
@@ -1966,13 +2022,26 @@ export async function submitRideModelCreation(
|
|||||||
maxAttempts: 3,
|
maxAttempts: 3,
|
||||||
baseDelay: 1000,
|
baseDelay: 1000,
|
||||||
onRetry: (attempt, error, delay) => {
|
onRetry: (attempt, error, delay) => {
|
||||||
|
const isRateLimit = isRateLimitError(error);
|
||||||
|
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
|
||||||
|
|
||||||
logger.warn('Retrying ride model submission', {
|
logger.warn('Retrying ride model submission', {
|
||||||
attempt,
|
attempt,
|
||||||
delay,
|
delay,
|
||||||
|
isRateLimit,
|
||||||
|
retryAfter,
|
||||||
error: error instanceof Error ? error.message : String(error)
|
error: error instanceof Error ? error.message : String(error)
|
||||||
});
|
});
|
||||||
window.dispatchEvent(new CustomEvent('submission-retry', {
|
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||||
detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'ride_model' }
|
detail: {
|
||||||
|
id: retryId,
|
||||||
|
attempt,
|
||||||
|
maxAttempts: 3,
|
||||||
|
delay,
|
||||||
|
type: 'ride_model',
|
||||||
|
isRateLimit,
|
||||||
|
retryAfter
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
shouldRetry: (error) => {
|
shouldRetry: (error) => {
|
||||||
@@ -2163,13 +2232,26 @@ export async function submitRideModelUpdate(
|
|||||||
maxAttempts: 3,
|
maxAttempts: 3,
|
||||||
baseDelay: 1000,
|
baseDelay: 1000,
|
||||||
onRetry: (attempt, error, delay) => {
|
onRetry: (attempt, error, delay) => {
|
||||||
|
const isRateLimit = isRateLimitError(error);
|
||||||
|
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
|
||||||
|
|
||||||
logger.warn('Retrying ride model update', {
|
logger.warn('Retrying ride model update', {
|
||||||
attempt,
|
attempt,
|
||||||
delay,
|
delay,
|
||||||
|
isRateLimit,
|
||||||
|
retryAfter,
|
||||||
error: error instanceof Error ? error.message : String(error)
|
error: error instanceof Error ? error.message : String(error)
|
||||||
});
|
});
|
||||||
window.dispatchEvent(new CustomEvent('submission-retry', {
|
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||||
detail: { id: retryId, 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) => {
|
||||||
@@ -2310,13 +2392,26 @@ export async function submitManufacturerCreation(
|
|||||||
maxAttempts: 3,
|
maxAttempts: 3,
|
||||||
baseDelay: 1000,
|
baseDelay: 1000,
|
||||||
onRetry: (attempt, error, delay) => {
|
onRetry: (attempt, error, delay) => {
|
||||||
|
const isRateLimit = isRateLimitError(error);
|
||||||
|
const retryAfter = isRateLimit ? extractRetryAfter(error) : null;
|
||||||
|
|
||||||
logger.warn('Retrying manufacturer submission', {
|
logger.warn('Retrying manufacturer submission', {
|
||||||
attempt,
|
attempt,
|
||||||
delay,
|
delay,
|
||||||
|
isRateLimit,
|
||||||
|
retryAfter,
|
||||||
error: error instanceof Error ? error.message : String(error)
|
error: error instanceof Error ? error.message : String(error)
|
||||||
});
|
});
|
||||||
window.dispatchEvent(new CustomEvent('submission-retry', {
|
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||||
detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'manufacturer' }
|
detail: {
|
||||||
|
id: retryId,
|
||||||
|
attempt,
|
||||||
|
maxAttempts: 3,
|
||||||
|
delay,
|
||||||
|
type: 'manufacturer',
|
||||||
|
isRateLimit,
|
||||||
|
retryAfter
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
shouldRetry: (error) => {
|
shouldRetry: (error) => {
|
||||||
@@ -2409,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
|
||||||
@@ -2446,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) => {
|
||||||
@@ -2520,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
|
||||||
@@ -2559,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) => {
|
||||||
@@ -2633,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
|
||||||
@@ -2670,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) => {
|
||||||
@@ -2925,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) => {
|
||||||
@@ -3141,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
|
||||||
@@ -3178,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) => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user