mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 07:51:13 -05:00
160 lines
4.5 KiB
TypeScript
160 lines
4.5 KiB
TypeScript
/**
|
|
* Idempotency Key Utilities
|
|
*
|
|
* Provides helper functions for generating and managing idempotency keys
|
|
* for moderation operations to prevent duplicate requests.
|
|
*
|
|
* Integrated with idempotencyLifecycle.ts for full lifecycle tracking.
|
|
*/
|
|
|
|
import {
|
|
registerIdempotencyKey,
|
|
updateIdempotencyStatus,
|
|
getIdempotencyRecord,
|
|
isIdempotencyKeyValid,
|
|
type IdempotencyRecord,
|
|
} from './idempotencyLifecycle';
|
|
|
|
/**
|
|
* 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));
|
|
}
|
|
|
|
/**
|
|
* Generate and register a new idempotency key with lifecycle tracking
|
|
*
|
|
* @param action - The moderation action type
|
|
* @param submissionId - The submission ID
|
|
* @param itemIds - Array of item IDs being processed
|
|
* @param userId - The moderator's user ID
|
|
* @returns Idempotency key and record
|
|
*/
|
|
export async function generateAndRegisterKey(
|
|
action: 'approval' | 'rejection' | 'retry',
|
|
submissionId: string,
|
|
itemIds: string[],
|
|
userId: string
|
|
): Promise<{ key: string; record: IdempotencyRecord }> {
|
|
const key = generateIdempotencyKey(action, submissionId, itemIds, userId);
|
|
const record = await registerIdempotencyKey(key, action, submissionId, itemIds, userId);
|
|
|
|
return { key, record };
|
|
}
|
|
|
|
/**
|
|
* Validate and mark idempotency key as processing
|
|
*
|
|
* @param key - Idempotency key to validate
|
|
* @returns True if valid and marked as processing
|
|
*/
|
|
export async function validateAndStartProcessing(key: string): Promise<boolean> {
|
|
const isValid = await isIdempotencyKeyValid(key);
|
|
|
|
if (!isValid) {
|
|
return false;
|
|
}
|
|
|
|
const record = await getIdempotencyRecord(key);
|
|
|
|
// Only allow transition from pending to processing
|
|
if (record?.status !== 'pending') {
|
|
return false;
|
|
}
|
|
|
|
await updateIdempotencyStatus(key, 'processing');
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Mark idempotency key as completed
|
|
*/
|
|
export async function markKeyCompleted(key: string): Promise<void> {
|
|
await updateIdempotencyStatus(key, 'completed');
|
|
}
|
|
|
|
/**
|
|
* Mark idempotency key as failed
|
|
*/
|
|
export async function markKeyFailed(key: string, error: string): Promise<void> {
|
|
await updateIdempotencyStatus(key, 'failed', error);
|
|
}
|