mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 22:11:24 -05:00
Refactor useModerationActions for resilience
Integrate transaction resilience features into the `useModerationActions` hook by refactoring the `invokeWithIdempotency` function. This change ensures that all moderation paths, including approvals, rejections, and retries, benefit from timeout detection, automatic lock release, and robust idempotency key management. The `invokeWithIdempotency` function has been replaced with a new `invokeWithResilience` function that incorporates these enhancements.
This commit is contained in:
@@ -10,8 +10,21 @@ import {
|
|||||||
generateIdempotencyKey,
|
generateIdempotencyKey,
|
||||||
is409Conflict,
|
is409Conflict,
|
||||||
getRetryAfter,
|
getRetryAfter,
|
||||||
sleep
|
sleep,
|
||||||
|
generateAndRegisterKey,
|
||||||
|
validateAndStartProcessing,
|
||||||
|
markKeyCompleted,
|
||||||
|
markKeyFailed,
|
||||||
} from '@/lib/idempotencyHelpers';
|
} from '@/lib/idempotencyHelpers';
|
||||||
|
import {
|
||||||
|
withTimeout,
|
||||||
|
isTimeoutError,
|
||||||
|
getTimeoutErrorMessage,
|
||||||
|
type TimeoutError,
|
||||||
|
} from '@/lib/timeoutDetection';
|
||||||
|
import {
|
||||||
|
autoReleaseLockOnError,
|
||||||
|
} from '@/lib/moderation/lockAutoRelease';
|
||||||
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';
|
||||||
|
|
||||||
@@ -49,27 +62,31 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invoke edge function with idempotency key and 409 retry logic
|
* Invoke edge function with full transaction resilience
|
||||||
*
|
*
|
||||||
* Wraps invokeWithTracking with:
|
* Provides:
|
||||||
* - Automatic idempotency key generation
|
* - Timeout detection with automatic recovery
|
||||||
* - Special handling for 409 Conflict responses
|
* - Lock auto-release on error/timeout
|
||||||
* - Exponential backoff retry for conflicts
|
* - Idempotency key lifecycle management
|
||||||
|
* - 409 Conflict handling with exponential backoff
|
||||||
*
|
*
|
||||||
* @param functionName - Edge function to invoke
|
* @param functionName - Edge function to invoke
|
||||||
* @param payload - Request payload
|
* @param payload - Request payload with submissionId
|
||||||
* @param idempotencyKey - Pre-generated idempotency key
|
* @param action - Action type for idempotency key generation
|
||||||
|
* @param itemIds - Item IDs being processed
|
||||||
* @param userId - User ID for tracking
|
* @param userId - User ID for tracking
|
||||||
* @param maxConflictRetries - Max retries for 409 responses (default: 3)
|
* @param maxConflictRetries - Max retries for 409 responses (default: 3)
|
||||||
|
* @param timeoutMs - Timeout in milliseconds (default: 30000)
|
||||||
* @returns Result with data, error, requestId, etc.
|
* @returns Result with data, error, requestId, etc.
|
||||||
*/
|
*/
|
||||||
async function invokeWithIdempotency<T = any>(
|
async function invokeWithResilience<T = any>(
|
||||||
functionName: string,
|
functionName: string,
|
||||||
payload: any,
|
payload: any,
|
||||||
idempotencyKey: string,
|
action: 'approval' | 'rejection' | 'retry',
|
||||||
|
itemIds: string[],
|
||||||
userId?: string,
|
userId?: string,
|
||||||
maxConflictRetries: number = 3,
|
maxConflictRetries: number = 3,
|
||||||
timeout: number = 30000
|
timeoutMs: number = 30000
|
||||||
): Promise<{
|
): Promise<{
|
||||||
data: T | null;
|
data: T | null;
|
||||||
error: any;
|
error: any;
|
||||||
@@ -79,28 +96,101 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
cached?: boolean;
|
cached?: boolean;
|
||||||
conflictRetries?: number;
|
conflictRetries?: number;
|
||||||
}> {
|
}> {
|
||||||
|
if (!userId) {
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
error: { message: 'User not authenticated' },
|
||||||
|
requestId: 'auth-error',
|
||||||
|
duration: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const submissionId = payload.submissionId;
|
||||||
|
if (!submissionId) {
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
error: { message: 'Missing submissionId in payload' },
|
||||||
|
requestId: 'validation-error',
|
||||||
|
duration: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate and register idempotency key
|
||||||
|
const { key: idempotencyKey } = await generateAndRegisterKey(
|
||||||
|
action,
|
||||||
|
submissionId,
|
||||||
|
itemIds,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('[ModerationResilience] Starting transaction', {
|
||||||
|
action,
|
||||||
|
submissionId,
|
||||||
|
itemIds,
|
||||||
|
idempotencyKey: idempotencyKey.substring(0, 32) + '...',
|
||||||
|
});
|
||||||
|
|
||||||
let conflictRetries = 0;
|
let conflictRetries = 0;
|
||||||
let lastError: any = null;
|
let lastError: any = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate key and mark as processing
|
||||||
|
const isValid = await validateAndStartProcessing(idempotencyKey);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
const error = new Error('Idempotency key validation failed - possible duplicate request');
|
||||||
|
await markKeyFailed(idempotencyKey, error.message);
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
error,
|
||||||
|
requestId: 'idempotency-validation-failed',
|
||||||
|
duration: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry loop for 409 conflicts
|
||||||
while (conflictRetries <= maxConflictRetries) {
|
while (conflictRetries <= maxConflictRetries) {
|
||||||
const result = await invokeWithTracking<T>(
|
try {
|
||||||
|
// Execute with timeout detection
|
||||||
|
const result = await withTimeout(
|
||||||
|
async () => {
|
||||||
|
return await invokeWithTracking<T>(
|
||||||
functionName,
|
functionName,
|
||||||
payload,
|
payload,
|
||||||
userId,
|
userId,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
timeout,
|
timeoutMs,
|
||||||
{ maxAttempts: 3, baseDelay: 1500 }, // Standard retry for transient errors
|
{ maxAttempts: 3, baseDelay: 1500 },
|
||||||
{ 'X-Idempotency-Key': idempotencyKey } // NEW: Custom header
|
{ 'X-Idempotency-Key': idempotencyKey }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
timeoutMs,
|
||||||
|
'edge-function'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Success or non-409 error - return immediately
|
// Success or non-409 error
|
||||||
if (!result.error || !is409Conflict(result.error)) {
|
if (!result.error || !is409Conflict(result.error)) {
|
||||||
// Check if response indicates cached result
|
|
||||||
const isCached = result.data && typeof result.data === 'object' && 'cached' in result.data
|
const isCached = result.data && typeof result.data === 'object' && 'cached' in result.data
|
||||||
? (result.data as any).cached
|
? (result.data as any).cached
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
|
// Mark key as completed on success
|
||||||
|
if (!result.error) {
|
||||||
|
await markKeyCompleted(idempotencyKey);
|
||||||
|
} else {
|
||||||
|
await markKeyFailed(idempotencyKey, getErrorMessage(result.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[ModerationResilience] Transaction completed', {
|
||||||
|
action,
|
||||||
|
submissionId,
|
||||||
|
idempotencyKey: idempotencyKey.substring(0, 32) + '...',
|
||||||
|
success: !result.error,
|
||||||
|
cached: isCached,
|
||||||
|
conflictRetries,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
cached: isCached,
|
cached: isCached,
|
||||||
@@ -113,17 +203,16 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
conflictRetries++;
|
conflictRetries++;
|
||||||
|
|
||||||
if (conflictRetries > maxConflictRetries) {
|
if (conflictRetries > maxConflictRetries) {
|
||||||
// Max retries exceeded
|
|
||||||
logger.error('Max 409 conflict retries exceeded', {
|
logger.error('Max 409 conflict retries exceeded', {
|
||||||
functionName,
|
functionName,
|
||||||
idempotencyKey: idempotencyKey.substring(0, 32) + '...',
|
idempotencyKey: idempotencyKey.substring(0, 32) + '...',
|
||||||
conflictRetries,
|
conflictRetries,
|
||||||
submissionId: payload.submissionId,
|
submissionId,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract retry-after from error and wait
|
// Wait before retry
|
||||||
const retryAfterSeconds = getRetryAfter(result.error);
|
const retryAfterSeconds = getRetryAfter(result.error);
|
||||||
const retryDelayMs = retryAfterSeconds * 1000;
|
const retryDelayMs = retryAfterSeconds * 1000;
|
||||||
|
|
||||||
@@ -134,9 +223,41 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
});
|
});
|
||||||
|
|
||||||
await sleep(retryDelayMs);
|
await sleep(retryDelayMs);
|
||||||
|
} catch (innerError) {
|
||||||
|
// Handle timeout errors specifically
|
||||||
|
if (isTimeoutError(innerError)) {
|
||||||
|
const timeoutError = innerError as TimeoutError;
|
||||||
|
const message = getTimeoutErrorMessage(timeoutError);
|
||||||
|
|
||||||
|
logger.error('[ModerationResilience] Transaction timed out', {
|
||||||
|
action,
|
||||||
|
submissionId,
|
||||||
|
idempotencyKey: idempotencyKey.substring(0, 32) + '...',
|
||||||
|
duration: timeoutError.duration,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-release lock on timeout
|
||||||
|
await autoReleaseLockOnError(submissionId, userId, timeoutError);
|
||||||
|
|
||||||
|
// Mark key as failed
|
||||||
|
await markKeyFailed(idempotencyKey, message);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
error: timeoutError,
|
||||||
|
requestId: 'timeout-error',
|
||||||
|
duration: timeoutError.duration || 0,
|
||||||
|
conflictRetries,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// All retries exhausted
|
// Re-throw non-timeout errors to outer catch
|
||||||
|
throw innerError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All conflict retries exhausted
|
||||||
|
await markKeyFailed(idempotencyKey, 'Max 409 conflict retries exceeded');
|
||||||
return {
|
return {
|
||||||
data: null,
|
data: null,
|
||||||
error: lastError || { message: 'Unknown conflict retry error' },
|
error: lastError || { message: 'Unknown conflict retry error' },
|
||||||
@@ -145,6 +266,31 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
attempts: 0,
|
attempts: 0,
|
||||||
conflictRetries,
|
conflictRetries,
|
||||||
};
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// Generic error handling
|
||||||
|
const errorMessage = getErrorMessage(error);
|
||||||
|
|
||||||
|
logger.error('[ModerationResilience] Transaction failed', {
|
||||||
|
action,
|
||||||
|
submissionId,
|
||||||
|
idempotencyKey: idempotencyKey.substring(0, 32) + '...',
|
||||||
|
error: errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-release lock on error
|
||||||
|
await autoReleaseLockOnError(submissionId, userId, error);
|
||||||
|
|
||||||
|
// Mark key as failed
|
||||||
|
await markKeyFailed(idempotencyKey, errorMessage);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
error,
|
||||||
|
requestId: 'error',
|
||||||
|
duration: 0,
|
||||||
|
conflictRetries,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -243,20 +389,6 @@ 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.
|
||||||
|
|
||||||
// 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 {
|
const {
|
||||||
data,
|
data,
|
||||||
error,
|
error,
|
||||||
@@ -264,13 +396,14 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
attempts,
|
attempts,
|
||||||
cached,
|
cached,
|
||||||
conflictRetries
|
conflictRetries
|
||||||
} = await invokeWithIdempotency(
|
} = await invokeWithResilience(
|
||||||
'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,
|
'approval',
|
||||||
|
submissionItems.map((i) => i.id),
|
||||||
config.user?.id,
|
config.user?.id,
|
||||||
3, // Max 3 conflict retries
|
3, // Max 3 conflict retries
|
||||||
30000 // 30s timeout
|
30000 // 30s timeout
|
||||||
@@ -411,9 +544,10 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
queryClient.setQueryData(['moderation-queue'], context.previousData);
|
queryClient.setQueryData(['moderation-queue'], context.previousData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enhanced error handling with reference ID and network detection
|
// Enhanced error handling with timeout, conflict, and network detection
|
||||||
const isNetworkError = isSupabaseConnectionError(error);
|
const isNetworkError = isSupabaseConnectionError(error);
|
||||||
const isConflict = is409Conflict(error); // NEW: Detect 409 conflicts
|
const isConflict = is409Conflict(error);
|
||||||
|
const isTimeout = isTimeoutError(error);
|
||||||
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
|
||||||
@@ -424,10 +558,13 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
toast({
|
toast({
|
||||||
title: isNetworkError ? 'Connection Error' :
|
title: isNetworkError ? 'Connection Error' :
|
||||||
isValidationError ? 'Validation Failed' :
|
isValidationError ? 'Validation Failed' :
|
||||||
isConflict ? 'Duplicate Request' : // NEW: Conflict title
|
isConflict ? 'Duplicate Request' :
|
||||||
|
isTimeout ? 'Transaction Timeout' :
|
||||||
'Action Failed',
|
'Action Failed',
|
||||||
description: isConflict
|
description: isTimeout
|
||||||
? 'This action is already being processed. Please wait for it to complete.' // NEW: Conflict message
|
? getTimeoutErrorMessage(error as TimeoutError)
|
||||||
|
: isConflict
|
||||||
|
? 'This action is already being processed. Please wait for it to complete.'
|
||||||
: errorMessage,
|
: errorMessage,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
@@ -439,7 +576,8 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
errorId: error.errorId,
|
errorId: error.errorId,
|
||||||
isNetworkError,
|
isNetworkError,
|
||||||
isValidationError,
|
isValidationError,
|
||||||
isConflict, // NEW: Log conflict status
|
isConflict,
|
||||||
|
isTimeout,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
@@ -642,20 +780,6 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
|
|
||||||
failedItemsCount = failedItems.length;
|
failedItemsCount = failedItems.length;
|
||||||
|
|
||||||
// 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 {
|
const {
|
||||||
data,
|
data,
|
||||||
error,
|
error,
|
||||||
@@ -663,13 +787,14 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
|||||||
attempts,
|
attempts,
|
||||||
cached,
|
cached,
|
||||||
conflictRetries
|
conflictRetries
|
||||||
} = await invokeWithIdempotency(
|
} = await invokeWithResilience(
|
||||||
'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,
|
'retry',
|
||||||
|
failedItems.map((i) => i.id),
|
||||||
config.user?.id,
|
config.user?.id,
|
||||||
3, // Max 3 conflict retries
|
3, // Max 3 conflict retries
|
||||||
30000 // 30s timeout
|
30000 // 30s timeout
|
||||||
|
|||||||
Reference in New Issue
Block a user