mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 12:31:14 -05:00
feat: Add idempotency to useModerationActions
Implement idempotency integration in the useModerationActions hook as per the detailed plan.
This commit is contained in:
@@ -19,7 +19,10 @@ import { breadcrumb } from './errorBreadcrumbs';
|
||||
* @param userId - User ID for tracking (optional)
|
||||
* @param parentRequestId - Parent request ID for chaining (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>(
|
||||
functionName: string,
|
||||
@@ -27,9 +30,10 @@ export async function invokeWithTracking<T = any>(
|
||||
userId?: string,
|
||||
parentRequestId?: string,
|
||||
traceId?: string,
|
||||
timeout: number = 30000, // Default 30s timeout
|
||||
retryOptions?: Partial<RetryOptions> // NEW: Optional retry configuration
|
||||
): Promise<{ data: T | null; error: any; requestId: string; duration: number; attempts?: number }> {
|
||||
timeout: number = 30000,
|
||||
retryOptions?: Partial<RetryOptions>,
|
||||
customHeaders?: Record<string, string>
|
||||
): Promise<{ data: T | null; error: any; requestId: string; duration: number; attempts?: number; status?: number }> {
|
||||
// Configure retry options with defaults
|
||||
const effectiveRetryOptions: RetryOptions = {
|
||||
maxAttempts: retryOptions?.maxAttempts ?? 3,
|
||||
@@ -75,14 +79,16 @@ export async function invokeWithTracking<T = any>(
|
||||
const { data, error } = await supabase.functions.invoke<T>(functionName, {
|
||||
body: { ...payload, clientRequestId: context.requestId },
|
||||
signal: controller.signal,
|
||||
headers: customHeaders,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
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');
|
||||
(enhancedError as any).status = error.status;
|
||||
(enhancedError as any).context = error.context;
|
||||
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) {
|
||||
// Handle AbortError specifically
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
@@ -110,16 +116,18 @@ export async function invokeWithTracking<T = any>(
|
||||
requestId: 'timeout',
|
||||
duration: timeout,
|
||||
attempts: attemptCount,
|
||||
status: 408,
|
||||
};
|
||||
}
|
||||
|
||||
const errorMessage = getErrorMessage(error);
|
||||
return {
|
||||
data: null,
|
||||
error: { message: errorMessage },
|
||||
error: { message: errorMessage, status: (error as any)?.status },
|
||||
requestId: 'unknown',
|
||||
duration: 0,
|
||||
attempts: attemptCount,
|
||||
status: (error as any)?.status,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -148,6 +156,7 @@ export async function invokeBatchWithTracking<T = any>(
|
||||
requestId: string;
|
||||
duration: number;
|
||||
attempts?: number;
|
||||
status?: number;
|
||||
}>
|
||||
> {
|
||||
const traceId = crypto.randomUUID();
|
||||
@@ -160,8 +169,8 @@ export async function invokeBatchWithTracking<T = any>(
|
||||
userId,
|
||||
undefined,
|
||||
traceId,
|
||||
30000, // default timeout
|
||||
op.retryOptions // Pass through retry options
|
||||
30000,
|
||||
op.retryOptions
|
||||
);
|
||||
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