diff --git a/src/components/ui/retry-status-indicator.tsx b/src/components/ui/retry-status-indicator.tsx index 4955f100..0c220871 100644 --- a/src/components/ui/retry-status-indicator.tsx +++ b/src/components/ui/retry-status-indicator.tsx @@ -12,6 +12,8 @@ interface RetryStatus { type: string; state: 'retrying' | 'success' | 'failed'; errorId?: string; + isRateLimit?: boolean; + retryAfter?: number; } /** @@ -24,12 +26,22 @@ export function RetryStatusIndicator() { useEffect(() => { const handleRetry = (event: Event) => { - const customEvent = event as CustomEvent>; - const { id, attempt, maxAttempts, delay, type } = customEvent.detail; + const customEvent = event as CustomEvent>; + const { id, attempt, maxAttempts, delay, type, isRateLimit, retryAfter } = customEvent.detail; setRetries(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; }); }; @@ -161,6 +173,17 @@ function RetryCard({ retry }: { retry: RetryStatus & { countdown: number } }) { // Retrying state 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 (
@@ -168,7 +191,7 @@ function RetryCard({ retry }: { retry: RetryStatus & { countdown: number } }) {

- Retrying submission... + {retry.isRateLimit ? 'Rate Limited' : 'Retrying submission...'}

{retry.attempt}/{retry.maxAttempts} @@ -176,7 +199,7 @@ function RetryCard({ retry }: { retry: RetryStatus & { countdown: number } }) {

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

diff --git a/src/lib/entitySubmissionHelpers.ts b/src/lib/entitySubmissionHelpers.ts index 9796d0c3..078efc01 100644 --- a/src/lib/entitySubmissionHelpers.ts +++ b/src/lib/entitySubmissionHelpers.ts @@ -9,7 +9,7 @@ import { logger } from './logger'; import { handleError } from './errorHandler'; import type { TimelineEventFormData, EntityType } from '@/types/timeline'; import { breadcrumb } from './errorBreadcrumbs'; -import { isRetryableError } from './retryHelpers'; +import { isRetryableError, isRateLimitError, extractRetryAfter } from './retryHelpers'; import { validateParkCreateFields, validateRideCreateFields, @@ -886,11 +886,28 @@ export async function submitParkCreation( maxAttempts: 3, baseDelay: 1000, 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', { - detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'park' } + detail: { + id: retryId, + attempt, + maxAttempts: 3, + delay, + type: 'park', + isRateLimit, + retryAfter + } })); }, shouldRetry: (error) => { @@ -1125,16 +1142,29 @@ export async function submitParkUpdate( maxAttempts: 3, baseDelay: 1000, onRetry: (attempt, error, delay) => { + const isRateLimit = isRateLimitError(error); + const retryAfter = isRateLimit ? extractRetryAfter(error) : null; + logger.warn('Retrying park update submission', { attempt, delay, parkId, + isRateLimit, + retryAfter, 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', { - detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'park update' } + detail: { + id: retryId, + attempt, + maxAttempts: 3, + delay, + type: 'park update', + isRateLimit, + retryAfter + } })); }, shouldRetry: (error) => { @@ -1529,15 +1559,28 @@ export async function submitRideCreation( maxAttempts: 3, baseDelay: 1000, onRetry: (attempt, error, delay) => { + const isRateLimit = isRateLimitError(error); + const retryAfter = isRateLimit ? extractRetryAfter(error) : null; + logger.warn('Retrying ride submission', { attempt, delay, + isRateLimit, + retryAfter, 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', { - detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'ride' } + detail: { + id: retryId, + attempt, + maxAttempts: 3, + delay, + type: 'ride', + isRateLimit, + retryAfter + } })); }, shouldRetry: (error) => { @@ -1747,16 +1790,29 @@ export async function submitRideUpdate( maxAttempts: 3, baseDelay: 1000, onRetry: (attempt, error, delay) => { + const isRateLimit = isRateLimitError(error); + const retryAfter = isRateLimit ? extractRetryAfter(error) : null; + logger.warn('Retrying ride update submission', { attempt, delay, rideId, + isRateLimit, + retryAfter, 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', { - detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'ride update' } + detail: { + id: retryId, + attempt, + maxAttempts: 3, + delay, + type: 'ride update', + isRateLimit, + retryAfter + } })); }, shouldRetry: (error) => { @@ -1966,13 +2022,26 @@ export async function submitRideModelCreation( maxAttempts: 3, baseDelay: 1000, onRetry: (attempt, error, 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', { - detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'ride_model' } + detail: { + id: retryId, + attempt, + maxAttempts: 3, + delay, + type: 'ride_model', + isRateLimit, + retryAfter + } })); }, shouldRetry: (error) => { @@ -2163,13 +2232,26 @@ export async function submitRideModelUpdate( maxAttempts: 3, baseDelay: 1000, onRetry: (attempt, error, 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', { - 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) => { @@ -2310,13 +2392,26 @@ export async function submitManufacturerCreation( maxAttempts: 3, baseDelay: 1000, onRetry: (attempt, error, 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', { - detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'manufacturer' } + detail: { + id: retryId, + attempt, + maxAttempts: 3, + delay, + type: 'manufacturer', + isRateLimit, + retryAfter + } })); }, shouldRetry: (error) => { @@ -2409,6 +2504,8 @@ export async function submitManufacturerUpdate( // Submit with retry logic breadcrumb.apiCall('content_submissions', 'INSERT'); + const retryId = crypto.randomUUID(); + const result = await withRetry( async () => { const { data: submissionData, error: submissionError } = await supabase @@ -2446,10 +2543,28 @@ export async function submitManufacturerUpdate( }, { maxAttempts: 3, + baseDelay: 1000, 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', { - detail: { attempt, maxAttempts: 3, delay, type: 'manufacturer_update' } + detail: { + id: retryId, + attempt, + maxAttempts: 3, + delay, + type: 'manufacturer_update', + isRateLimit, + retryAfter + } })); }, shouldRetry: (error) => { @@ -2520,6 +2635,8 @@ export async function submitDesignerCreation( // Submit with retry logic breadcrumb.apiCall('content_submissions', 'INSERT'); + const retryId = crypto.randomUUID(); + const result = await withRetry( async () => { const { data: submissionData, error: submissionError } = await supabase @@ -2559,10 +2676,28 @@ export async function submitDesignerCreation( }, { maxAttempts: 3, + baseDelay: 1000, 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', { - detail: { attempt, maxAttempts: 3, delay, type: 'designer' } + detail: { + id: retryId, + attempt, + maxAttempts: 3, + delay, + type: 'designer', + isRateLimit, + retryAfter + } })); }, shouldRetry: (error) => { @@ -2633,6 +2768,8 @@ export async function submitDesignerUpdate( // Submit with retry logic breadcrumb.apiCall('content_submissions', 'INSERT'); + const retryId = crypto.randomUUID(); + const result = await withRetry( async () => { const { data: submissionData, error: submissionError } = await supabase @@ -2670,10 +2807,28 @@ export async function submitDesignerUpdate( }, { maxAttempts: 3, + baseDelay: 1000, 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', { - detail: { attempt, maxAttempts: 3, delay, type: 'designer_update' } + detail: { + id: retryId, + attempt, + maxAttempts: 3, + delay, + type: 'designer_update', + isRateLimit, + retryAfter + } })); }, shouldRetry: (error) => { @@ -2925,10 +3080,28 @@ export async function submitOperatorUpdate( }, { maxAttempts: 3, + baseDelay: 1000, 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', { - detail: { attempt, maxAttempts: 3, delay, type: 'operator_update' } + detail: { + id: retryId, + attempt, + maxAttempts: 3, + delay, + type: 'operator_update', + isRateLimit, + retryAfter + } })); }, shouldRetry: (error) => { @@ -3141,6 +3314,8 @@ export async function submitPropertyOwnerUpdate( // Submit with retry logic breadcrumb.apiCall('content_submissions', 'INSERT'); + const retryId = crypto.randomUUID(); + const result = await withRetry( async () => { const { data: submissionData, error: submissionError } = await supabase @@ -3178,10 +3353,28 @@ export async function submitPropertyOwnerUpdate( }, { maxAttempts: 3, + baseDelay: 1000, 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', { - detail: { attempt, maxAttempts: 3, delay, type: 'property_owner_update' } + detail: { + id: retryId, + attempt, + maxAttempts: 3, + delay, + type: 'property_owner_update', + isRateLimit, + retryAfter + } })); }, shouldRetry: (error) => { diff --git a/src/lib/retryHelpers.ts b/src/lib/retryHelpers.ts index 643ad6bd..eeb71b5f 100644 --- a/src/lib/retryHelpers.ts +++ b/src/lib/retryHelpers.ts @@ -23,6 +23,97 @@ export interface RetryOptions { 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; 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 * @param error - The error to check @@ -56,7 +147,7 @@ export function isRetryableError(error: unknown): boolean { if (supabaseError.code === 'PGRST000') return true; // Connection error // 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 === 504) return true; // Gateway timeout 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 options - Retry configuration + * @param error - The error that triggered the retry (to check for Retry-After) * @returns Delay in milliseconds */ -function calculateBackoffDelay(attempt: number, options: Required): number { +function calculateBackoffDelay( + attempt: number, + options: Required, + 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 cappedDelay = Math.min(exponentialDelay, options.maxDelay); @@ -246,18 +371,23 @@ export async function withRetry( throw error; } - // Calculate delay for next attempt - const delay = calculateBackoffDelay(attempt, config); + // Calculate delay for next attempt (respects Retry-After for rate limits) + 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', { attempt: attempt + 1, maxAttempts: config.maxAttempts, delay, + isRateLimit, + retryAfterMs: retryAfter, error: error instanceof Error ? error.message : String(error) }); - // Invoke callback + // Invoke callback with additional context config.onRetry(attempt + 1, error, delay); // Wait before retrying