mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 13:31:12 -05:00
feat: Add idempotency to useModerationActions
Implement idempotency integration in the useModerationActions hook as per the detailed plan.
This commit is contained in:
@@ -6,6 +6,12 @@ import { logger } from '@/lib/logger';
|
|||||||
import { getErrorMessage, handleError, isSupabaseConnectionError } from '@/lib/errorHandler';
|
import { getErrorMessage, handleError, isSupabaseConnectionError } from '@/lib/errorHandler';
|
||||||
// Validation removed from client - edge function is single source of truth
|
// Validation removed from client - edge function is single source of truth
|
||||||
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
|
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
|
||||||
|
import {
|
||||||
|
generateIdempotencyKey,
|
||||||
|
is409Conflict,
|
||||||
|
getRetryAfter,
|
||||||
|
sleep
|
||||||
|
} from '@/lib/idempotencyHelpers';
|
||||||
import type { User } from '@supabase/supabase-js';
|
import type { User } from '@supabase/supabase-js';
|
||||||
import type { ModerationItem } from '@/types/moderation';
|
import type { ModerationItem } from '@/types/moderation';
|
||||||
|
|
||||||
@@ -42,6 +48,105 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke edge function with idempotency key and 409 retry logic
|
||||||
|
*
|
||||||
|
* Wraps invokeWithTracking with:
|
||||||
|
* - Automatic idempotency key generation
|
||||||
|
* - Special handling for 409 Conflict responses
|
||||||
|
* - Exponential backoff retry for conflicts
|
||||||
|
*
|
||||||
|
* @param functionName - Edge function to invoke
|
||||||
|
* @param payload - Request payload
|
||||||
|
* @param idempotencyKey - Pre-generated idempotency key
|
||||||
|
* @param userId - User ID for tracking
|
||||||
|
* @param maxConflictRetries - Max retries for 409 responses (default: 3)
|
||||||
|
* @returns Result with data, error, requestId, etc.
|
||||||
|
*/
|
||||||
|
async function invokeWithIdempotency<T = any>(
|
||||||
|
functionName: string,
|
||||||
|
payload: any,
|
||||||
|
idempotencyKey: string,
|
||||||
|
userId?: string,
|
||||||
|
maxConflictRetries: number = 3,
|
||||||
|
timeout: number = 30000
|
||||||
|
): Promise<{
|
||||||
|
data: T | null;
|
||||||
|
error: any;
|
||||||
|
requestId: string;
|
||||||
|
duration: number;
|
||||||
|
attempts?: number;
|
||||||
|
cached?: boolean;
|
||||||
|
conflictRetries?: number;
|
||||||
|
}> {
|
||||||
|
let conflictRetries = 0;
|
||||||
|
let lastError: any = null;
|
||||||
|
|
||||||
|
while (conflictRetries <= maxConflictRetries) {
|
||||||
|
const result = await invokeWithTracking<T>(
|
||||||
|
functionName,
|
||||||
|
payload,
|
||||||
|
userId,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
timeout,
|
||||||
|
{ maxAttempts: 3, baseDelay: 1500 }, // Standard retry for transient errors
|
||||||
|
{ 'X-Idempotency-Key': idempotencyKey } // NEW: Custom header
|
||||||
|
);
|
||||||
|
|
||||||
|
// Success or non-409 error - return immediately
|
||||||
|
if (!result.error || !is409Conflict(result.error)) {
|
||||||
|
// Check if response indicates cached result
|
||||||
|
const isCached = result.data && typeof result.data === 'object' && 'cached' in result.data
|
||||||
|
? (result.data as any).cached
|
||||||
|
: false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
cached: isCached,
|
||||||
|
conflictRetries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 409 Conflict detected
|
||||||
|
lastError = result.error;
|
||||||
|
conflictRetries++;
|
||||||
|
|
||||||
|
if (conflictRetries > maxConflictRetries) {
|
||||||
|
// Max retries exceeded
|
||||||
|
logger.error('Max 409 conflict retries exceeded', {
|
||||||
|
functionName,
|
||||||
|
idempotencyKey: idempotencyKey.substring(0, 32) + '...',
|
||||||
|
conflictRetries,
|
||||||
|
submissionId: payload.submissionId,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract retry-after from error and wait
|
||||||
|
const retryAfterSeconds = getRetryAfter(result.error);
|
||||||
|
const retryDelayMs = retryAfterSeconds * 1000;
|
||||||
|
|
||||||
|
logger.log(`409 Conflict detected, retrying after ${retryAfterSeconds}s (attempt ${conflictRetries}/${maxConflictRetries})`, {
|
||||||
|
functionName,
|
||||||
|
idempotencyKey: idempotencyKey.substring(0, 32) + '...',
|
||||||
|
retryAfterSeconds,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleep(retryDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All retries exhausted
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
error: lastError || { message: 'Unknown conflict retry error' },
|
||||||
|
requestId: 'conflict-retry-failed',
|
||||||
|
duration: 0,
|
||||||
|
attempts: 0,
|
||||||
|
conflictRetries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform moderation action (approve/reject) with optimistic updates
|
* Perform moderation action (approve/reject) with optimistic updates
|
||||||
*/
|
*/
|
||||||
@@ -138,29 +243,70 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
// Client-side only performs basic UX validation (non-empty, format) in forms.
|
// Client-side only performs basic UX validation (non-empty, format) in forms.
|
||||||
// If server-side validation fails, the edge function returns detailed 400/500 errors.
|
// If server-side validation fails, the edge function returns detailed 400/500 errors.
|
||||||
|
|
||||||
const { data, error, requestId, attempts } = await invokeWithTracking(
|
// Generate idempotency key BEFORE calling edge function
|
||||||
|
const idempotencyKey = generateIdempotencyKey(
|
||||||
|
'approval',
|
||||||
|
item.id,
|
||||||
|
submissionItems.map((i) => i.id),
|
||||||
|
config.user?.id || 'unknown'
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.log('Generated idempotency key for approval', {
|
||||||
|
submissionId: item.id,
|
||||||
|
itemCount: submissionItems.length,
|
||||||
|
idempotencyKey: idempotencyKey.substring(0, 32) + '...', // Log partial key
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
requestId,
|
||||||
|
attempts,
|
||||||
|
cached,
|
||||||
|
conflictRetries
|
||||||
|
} = await invokeWithIdempotency(
|
||||||
'process-selective-approval',
|
'process-selective-approval',
|
||||||
{
|
{
|
||||||
itemIds: submissionItems.map((i) => i.id),
|
itemIds: submissionItems.map((i) => i.id),
|
||||||
submissionId: item.id,
|
submissionId: item.id,
|
||||||
},
|
},
|
||||||
|
idempotencyKey,
|
||||||
config.user?.id,
|
config.user?.id,
|
||||||
undefined,
|
3, // Max 3 conflict retries
|
||||||
undefined,
|
30000 // 30s timeout
|
||||||
30000, // 30s timeout
|
|
||||||
{ maxAttempts: 3, baseDelay: 1500 } // Critical operation - retry config
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Log if retries were needed
|
// Log retry attempts
|
||||||
if (attempts && attempts > 1) {
|
if (attempts && attempts > 1) {
|
||||||
logger.log(`Approval succeeded after ${attempts} attempts for ${item.id}`);
|
logger.log(`Approval succeeded after ${attempts} network retries`, {
|
||||||
|
submissionId: item.id,
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) throw error;
|
if (conflictRetries && conflictRetries > 0) {
|
||||||
|
logger.log(`Resolved 409 conflict after ${conflictRetries} retries`, {
|
||||||
|
submissionId: item.id,
|
||||||
|
requestId,
|
||||||
|
cached: !!cached,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
// Enhance error with context for better UI feedback
|
||||||
|
if (is409Conflict(error)) {
|
||||||
|
throw new Error(
|
||||||
|
'This approval is being processed by another request. Please wait and try again if it does not complete.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Submission Approved',
|
title: cached ? 'Cached Result' : 'Submission Approved',
|
||||||
description: `Successfully processed ${submissionItems.length} item(s)${requestId ? ` (Request: ${requestId.substring(0, 8)})` : ''}`,
|
description: cached
|
||||||
|
? `Returned cached result for ${submissionItems.length} item(s)`
|
||||||
|
: `Successfully processed ${submissionItems.length} item(s)${requestId ? ` (Request: ${requestId.substring(0, 8)})` : ''}`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} else if (action === 'rejected') {
|
} else if (action === 'rejected') {
|
||||||
@@ -267,6 +413,7 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
|
|
||||||
// Enhanced error handling with reference ID and network detection
|
// Enhanced error handling with reference ID and network detection
|
||||||
const isNetworkError = isSupabaseConnectionError(error);
|
const isNetworkError = isSupabaseConnectionError(error);
|
||||||
|
const isConflict = is409Conflict(error); // NEW: Detect 409 conflicts
|
||||||
const errorMessage = getErrorMessage(error) || `Failed to ${variables.action} content`;
|
const errorMessage = getErrorMessage(error) || `Failed to ${variables.action} content`;
|
||||||
|
|
||||||
// Check if this is a validation error from edge function
|
// Check if this is a validation error from edge function
|
||||||
@@ -276,8 +423,12 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: isNetworkError ? 'Connection Error' :
|
title: isNetworkError ? 'Connection Error' :
|
||||||
isValidationError ? 'Validation Failed' : 'Action Failed',
|
isValidationError ? 'Validation Failed' :
|
||||||
description: errorMessage,
|
isConflict ? 'Duplicate Request' : // NEW: Conflict title
|
||||||
|
'Action Failed',
|
||||||
|
description: isConflict
|
||||||
|
? 'This action is already being processed. Please wait for it to complete.' // NEW: Conflict message
|
||||||
|
: errorMessage,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -288,6 +439,7 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
errorId: error.errorId,
|
errorId: error.errorId,
|
||||||
isNetworkError,
|
isNetworkError,
|
||||||
isValidationError,
|
isValidationError,
|
||||||
|
isConflict, // NEW: Log conflict status
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
@@ -490,24 +642,62 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
|
|
||||||
failedItemsCount = failedItems.length;
|
failedItemsCount = failedItems.length;
|
||||||
|
|
||||||
const { data, error, requestId, attempts } = await invokeWithTracking(
|
// Generate idempotency key for retry operation
|
||||||
|
const idempotencyKey = generateIdempotencyKey(
|
||||||
|
'retry',
|
||||||
|
item.id,
|
||||||
|
failedItems.map((i) => i.id),
|
||||||
|
config.user?.id || 'unknown'
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.log('Generated idempotency key for retry', {
|
||||||
|
submissionId: item.id,
|
||||||
|
itemCount: failedItems.length,
|
||||||
|
idempotencyKey: idempotencyKey.substring(0, 32) + '...',
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
requestId,
|
||||||
|
attempts,
|
||||||
|
cached,
|
||||||
|
conflictRetries
|
||||||
|
} = await invokeWithIdempotency(
|
||||||
'process-selective-approval',
|
'process-selective-approval',
|
||||||
{
|
{
|
||||||
itemIds: failedItems.map((i) => i.id),
|
itemIds: failedItems.map((i) => i.id),
|
||||||
submissionId: item.id,
|
submissionId: item.id,
|
||||||
},
|
},
|
||||||
|
idempotencyKey,
|
||||||
config.user?.id,
|
config.user?.id,
|
||||||
undefined,
|
3, // Max 3 conflict retries
|
||||||
undefined,
|
30000 // 30s timeout
|
||||||
30000,
|
|
||||||
{ maxAttempts: 3, baseDelay: 1500 } // Retry for failed items
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (attempts && attempts > 1) {
|
if (attempts && attempts > 1) {
|
||||||
logger.log(`Retry succeeded after ${attempts} attempts for ${item.id}`);
|
logger.log(`Retry succeeded after ${attempts} network retries`, {
|
||||||
|
submissionId: item.id,
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) throw error;
|
if (conflictRetries && conflictRetries > 0) {
|
||||||
|
logger.log(`Retry resolved 409 conflict after ${conflictRetries} retries`, {
|
||||||
|
submissionId: item.id,
|
||||||
|
requestId,
|
||||||
|
cached: !!cached,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (is409Conflict(error)) {
|
||||||
|
throw new Error(
|
||||||
|
'This retry is being processed by another request. Please wait and try again if it does not complete.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
// Log audit trail for retry
|
// Log audit trail for retry
|
||||||
if (user) {
|
if (user) {
|
||||||
@@ -529,8 +719,10 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Items Retried',
|
title: cached ? 'Cached Retry Result' : 'Items Retried',
|
||||||
description: `Successfully retried ${failedItems.length} failed item(s)${requestId ? ` (Request: ${requestId.substring(0, 8)})` : ''}`,
|
description: cached
|
||||||
|
? `Returned cached result for ${failedItems.length} item(s)`
|
||||||
|
: `Successfully retried ${failedItems.length} failed item(s)${requestId ? ` (Request: ${requestId.substring(0, 8)})` : ''}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.log(`✅ Retried ${failedItems.length} failed items for ${item.id}`);
|
logger.log(`✅ Retried ${failedItems.length} failed items for ${item.id}`);
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ import { breadcrumb } from './errorBreadcrumbs';
|
|||||||
* @param userId - User ID for tracking (optional)
|
* @param userId - User ID for tracking (optional)
|
||||||
* @param parentRequestId - Parent request ID for chaining (optional)
|
* @param parentRequestId - Parent request ID for chaining (optional)
|
||||||
* @param traceId - Trace ID for distributed tracing (optional)
|
* @param traceId - Trace ID for distributed tracing (optional)
|
||||||
* @returns Response data with requestId
|
* @param timeout - Request timeout in milliseconds (default: 30000)
|
||||||
|
* @param retryOptions - Optional retry configuration
|
||||||
|
* @param customHeaders - Custom headers to include in the request (e.g., X-Idempotency-Key)
|
||||||
|
* @returns Response data with requestId, status, and tracking info
|
||||||
*/
|
*/
|
||||||
export async function invokeWithTracking<T = any>(
|
export async function invokeWithTracking<T = any>(
|
||||||
functionName: string,
|
functionName: string,
|
||||||
@@ -27,9 +30,10 @@ export async function invokeWithTracking<T = any>(
|
|||||||
userId?: string,
|
userId?: string,
|
||||||
parentRequestId?: string,
|
parentRequestId?: string,
|
||||||
traceId?: string,
|
traceId?: string,
|
||||||
timeout: number = 30000, // Default 30s timeout
|
timeout: number = 30000,
|
||||||
retryOptions?: Partial<RetryOptions> // NEW: Optional retry configuration
|
retryOptions?: Partial<RetryOptions>,
|
||||||
): Promise<{ data: T | null; error: any; requestId: string; duration: number; attempts?: number }> {
|
customHeaders?: Record<string, string>
|
||||||
|
): Promise<{ data: T | null; error: any; requestId: string; duration: number; attempts?: number; status?: number }> {
|
||||||
// Configure retry options with defaults
|
// Configure retry options with defaults
|
||||||
const effectiveRetryOptions: RetryOptions = {
|
const effectiveRetryOptions: RetryOptions = {
|
||||||
maxAttempts: retryOptions?.maxAttempts ?? 3,
|
maxAttempts: retryOptions?.maxAttempts ?? 3,
|
||||||
@@ -75,14 +79,16 @@ export async function invokeWithTracking<T = any>(
|
|||||||
const { data, error } = await supabase.functions.invoke<T>(functionName, {
|
const { data, error } = await supabase.functions.invoke<T>(functionName, {
|
||||||
body: { ...payload, clientRequestId: context.requestId },
|
body: { ...payload, clientRequestId: context.requestId },
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
|
headers: customHeaders,
|
||||||
});
|
});
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
// Enhance error with status for retry logic
|
// Enhance error with status and context for retry logic
|
||||||
const enhancedError = new Error(error.message || 'Edge function error');
|
const enhancedError = new Error(error.message || 'Edge function error');
|
||||||
(enhancedError as any).status = error.status;
|
(enhancedError as any).status = error.status;
|
||||||
|
(enhancedError as any).context = error.context;
|
||||||
throw enhancedError;
|
throw enhancedError;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +103,7 @@ export async function invokeWithTracking<T = any>(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return { data: result, error: null, requestId, duration, attempts: attemptCount };
|
return { data: result, error: null, requestId, duration, attempts: attemptCount, status: 200 };
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// Handle AbortError specifically
|
// Handle AbortError specifically
|
||||||
if (error instanceof Error && error.name === 'AbortError') {
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
@@ -110,16 +116,18 @@ export async function invokeWithTracking<T = any>(
|
|||||||
requestId: 'timeout',
|
requestId: 'timeout',
|
||||||
duration: timeout,
|
duration: timeout,
|
||||||
attempts: attemptCount,
|
attempts: attemptCount,
|
||||||
|
status: 408,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorMessage = getErrorMessage(error);
|
const errorMessage = getErrorMessage(error);
|
||||||
return {
|
return {
|
||||||
data: null,
|
data: null,
|
||||||
error: { message: errorMessage },
|
error: { message: errorMessage, status: (error as any)?.status },
|
||||||
requestId: 'unknown',
|
requestId: 'unknown',
|
||||||
duration: 0,
|
duration: 0,
|
||||||
attempts: attemptCount,
|
attempts: attemptCount,
|
||||||
|
status: (error as any)?.status,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,6 +156,7 @@ export async function invokeBatchWithTracking<T = any>(
|
|||||||
requestId: string;
|
requestId: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
attempts?: number;
|
attempts?: number;
|
||||||
|
status?: number;
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
const traceId = crypto.randomUUID();
|
const traceId = crypto.randomUUID();
|
||||||
@@ -160,8 +169,8 @@ export async function invokeBatchWithTracking<T = any>(
|
|||||||
userId,
|
userId,
|
||||||
undefined,
|
undefined,
|
||||||
traceId,
|
traceId,
|
||||||
30000, // default timeout
|
30000,
|
||||||
op.retryOptions // Pass through retry options
|
op.retryOptions
|
||||||
);
|
);
|
||||||
return { functionName: op.functionName, ...result };
|
return { functionName: op.functionName, ...result };
|
||||||
})
|
})
|
||||||
|
|||||||
90
src/lib/idempotencyHelpers.ts
Normal file
90
src/lib/idempotencyHelpers.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Idempotency Key Utilities
|
||||||
|
*
|
||||||
|
* Provides helper functions for generating and managing idempotency keys
|
||||||
|
* for moderation operations to prevent duplicate requests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a deterministic idempotency key for a moderation action
|
||||||
|
*
|
||||||
|
* Format: action_submissionId_itemIds_userId_timestamp
|
||||||
|
* Example: approval_abc123_def456_ghi789_user123_1699564800000
|
||||||
|
*
|
||||||
|
* @param action - The moderation action type ('approval', 'rejection', 'retry')
|
||||||
|
* @param submissionId - The submission ID
|
||||||
|
* @param itemIds - Array of item IDs being processed
|
||||||
|
* @param userId - The moderator's user ID
|
||||||
|
* @returns Deterministic idempotency key
|
||||||
|
*/
|
||||||
|
export function generateIdempotencyKey(
|
||||||
|
action: 'approval' | 'rejection' | 'retry',
|
||||||
|
submissionId: string,
|
||||||
|
itemIds: string[],
|
||||||
|
userId: string
|
||||||
|
): string {
|
||||||
|
// Sort itemIds to ensure consistency regardless of order
|
||||||
|
const sortedItemIds = [...itemIds].sort().join('_');
|
||||||
|
|
||||||
|
// Include timestamp to allow same moderator to retry after 24h window
|
||||||
|
const timestamp = Date.now();
|
||||||
|
|
||||||
|
return `${action}_${submissionId}_${sortedItemIds}_${userId}_${timestamp}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error is a 409 Conflict (duplicate request)
|
||||||
|
*
|
||||||
|
* @param error - Error object to check
|
||||||
|
* @returns True if error is 409 Conflict
|
||||||
|
*/
|
||||||
|
export function is409Conflict(error: unknown): boolean {
|
||||||
|
if (!error || typeof error !== 'object') return false;
|
||||||
|
|
||||||
|
const errorObj = error as { status?: number; message?: string };
|
||||||
|
|
||||||
|
// Check status code
|
||||||
|
if (errorObj.status === 409) return true;
|
||||||
|
|
||||||
|
// Check error message for conflict indicators
|
||||||
|
const message = errorObj.message?.toLowerCase() || '';
|
||||||
|
return message.includes('duplicate request') ||
|
||||||
|
message.includes('already in progress') ||
|
||||||
|
message.includes('race condition');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract retry-after value from error response
|
||||||
|
*
|
||||||
|
* @param error - Error object with potential Retry-After header
|
||||||
|
* @returns Seconds to wait before retry, defaults to 3
|
||||||
|
*/
|
||||||
|
export function getRetryAfter(error: unknown): number {
|
||||||
|
if (!error || typeof error !== 'object') return 3;
|
||||||
|
|
||||||
|
const errorObj = error as {
|
||||||
|
retryAfter?: number;
|
||||||
|
context?: { headers?: { 'Retry-After'?: string } }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check structured retryAfter field
|
||||||
|
if (errorObj.retryAfter) return errorObj.retryAfter;
|
||||||
|
|
||||||
|
// Check Retry-After header
|
||||||
|
const retryAfterHeader = errorObj.context?.headers?.['Retry-After'];
|
||||||
|
if (retryAfterHeader) {
|
||||||
|
const seconds = parseInt(retryAfterHeader, 10);
|
||||||
|
return isNaN(seconds) ? 3 : seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 3; // Default 3 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep for a specified duration
|
||||||
|
*
|
||||||
|
* @param ms - Milliseconds to sleep
|
||||||
|
*/
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user