feat: Add idempotency to useModerationActions

Implement idempotency integration in the useModerationActions hook as per the detailed plan.
This commit is contained in:
gpt-engineer-app[bot]
2025-11-06 17:43:16 +00:00
parent 85436b5c1e
commit b92a62ebc8
3 changed files with 321 additions and 30 deletions

View File

@@ -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}`);

View File

@@ -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 };
}) })

View 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));
}