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:
gpt-engineer-app[bot]
2025-11-07 15:53:54 +00:00
parent fc8631ff0b
commit b917232220

View File

@@ -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