From 85436b5c1e823bbd1b35d80ad36b7471e4df7433 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 6 Nov 2025 17:24:21 +0000 Subject: [PATCH] feat: Integrate idempotency Implement idempotency for the process-selective-approval edge function as per the detailed plan. --- .../process-selective-approval/index.ts | 338 +++++++++++++++++- 1 file changed, 329 insertions(+), 9 deletions(-) diff --git a/supabase/functions/process-selective-approval/index.ts b/supabase/functions/process-selective-approval/index.ts index f4277bd9..95a44493 100644 --- a/supabase/functions/process-selective-approval/index.ts +++ b/supabase/functions/process-selective-approval/index.ts @@ -662,7 +662,157 @@ serve(withRateLimit(async (req) => { edgeLogger.info('AAL2 check passed', { action: 'approval_aal_pass', userId: authenticatedUserId, hasMFA, aal }); - const { itemIds, submissionId }: ApprovalRequest = await req.json(); + // ============================================================================ + // IDEMPOTENCY: Parse request and extract/generate idempotency key + // ============================================================================ + + const requestBody = await req.json(); + const { itemIds, submissionId }: ApprovalRequest = requestBody; + + // Extract idempotency key from header or generate deterministic key + const idempotencyKey = req.headers.get('X-Idempotency-Key') || + `approval_${submissionId}_${itemIds.sort().join('_')}_${authenticatedUserId}`; + + edgeLogger.info('Idempotency key extracted', { + action: 'approval_idempotency_key', + idempotencyKey, + hasCustomKey: !!req.headers.get('X-Idempotency-Key'), + requestId: tracking.requestId + }); + + // ============================================================================ + // IDEMPOTENCY: Check for existing key within 24h window + // ============================================================================ + + const { data: existingKey, error: keyError } = await supabase + .from('submission_idempotency_keys') + .select('*') + .eq('idempotency_key', idempotencyKey) + .eq('moderator_id', authenticatedUserId) + .gte('expires_at', new Date().toISOString()) + .maybeSingle(); + + if (keyError) { + edgeLogger.error('Failed to check idempotency key', { + action: 'approval_idempotency_check_error', + error: keyError.message, + requestId: tracking.requestId + }); + // Don't fail - continue without idempotency protection + } + + // ============================================================================ + // CASE 1: Key exists and completed - return cached result + // ============================================================================ + if (existingKey && existingKey.status === 'completed') { + const cacheAge = Date.now() - new Date(existingKey.created_at).getTime(); + + edgeLogger.info('Idempotency cache HIT - returning cached result', { + action: 'approval_idempotency_hit', + idempotencyKey, + originalRequestId: existingKey.request_id, + requestId: tracking.requestId, + cacheAgeMs: cacheAge, + originalDurationMs: existingKey.duration_ms + }); + + const duration = endRequest(tracking); + + return new Response( + JSON.stringify({ + ...existingKey.result_data, + cached: true, + cacheAgeMs: cacheAge, + originalRequestId: existingKey.request_id, + originalTimestamp: existingKey.created_at, + requestId: tracking.requestId + }), + { + headers: { + ...corsHeaders, + 'Content-Type': 'application/json', + 'X-Request-ID': tracking.requestId, + 'X-Original-Request-ID': existingKey.request_id || '', + 'X-Idempotency-Key': idempotencyKey, + 'X-Cache-Status': 'HIT', + 'X-Cache-Age-MS': cacheAge.toString() + } + } + ); + } + + // ============================================================================ + // CASE 2: Key exists and still processing - reject with 409 Conflict + // ============================================================================ + if (existingKey && existingKey.status === 'processing') { + const processingTime = Date.now() - new Date(existingKey.created_at).getTime(); + + edgeLogger.warn('Duplicate request detected while processing', { + action: 'approval_idempotency_conflict', + idempotencyKey, + processingTimeMs: processingTime, + originalRequestId: existingKey.request_id, + requestId: tracking.requestId + }); + + const duration = endRequest(tracking); + + return new Response( + JSON.stringify({ + error: 'Request already in progress', + code: 'DUPLICATE_REQUEST', + message: 'This approval is already being processed by another request. Please wait for the original request to complete.', + processingTimeMs: processingTime, + originalRequestId: existingKey.request_id, + originalTimestamp: existingKey.created_at, + requestId: tracking.requestId, + retryAfter: 5 + }), + { + status: 409, + headers: { + ...corsHeaders, + 'Content-Type': 'application/json', + 'X-Request-ID': tracking.requestId, + 'X-Original-Request-ID': existingKey.request_id || '', + 'X-Idempotency-Key': idempotencyKey, + 'Retry-After': '5', + 'X-Processing-Time-MS': processingTime.toString() + } + } + ); + } + + // ============================================================================ + // CASE 3: Key exists and failed - allow retry (delete old key) + // ============================================================================ + if (existingKey && existingKey.status === 'failed') { + const timeSinceFailure = Date.now() - new Date(existingKey.completed_at || existingKey.created_at).getTime(); + + edgeLogger.info('Retrying previously failed request', { + action: 'approval_idempotency_retry', + idempotencyKey, + previousError: existingKey.error_message, + timeSinceFailureMs: timeSinceFailure, + requestId: tracking.requestId + }); + + // Delete the failed key to allow fresh attempt + await supabase + .from('submission_idempotency_keys') + .delete() + .eq('id', existingKey.id); + } + + // ============================================================================ + // CASE 4: No existing key or retry - proceed with validation + // ============================================================================ + edgeLogger.info('Idempotency check passed - proceeding with validation', { + action: 'approval_idempotency_pass', + idempotencyKey, + isRetry: !!existingKey, + requestId: tracking.requestId + }); // UUID validation regex const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -676,7 +826,8 @@ serve(withRateLimit(async (req) => { headers: { ...corsHeaders, 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId + 'X-Request-ID': tracking.requestId, + 'X-Idempotency-Key': idempotencyKey } } ); @@ -690,7 +841,8 @@ serve(withRateLimit(async (req) => { headers: { ...corsHeaders, 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId + 'X-Request-ID': tracking.requestId, + 'X-Idempotency-Key': idempotencyKey } } ); @@ -705,7 +857,8 @@ serve(withRateLimit(async (req) => { headers: { ...corsHeaders, 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId + 'X-Request-ID': tracking.requestId, + 'X-Idempotency-Key': idempotencyKey } } ); @@ -719,7 +872,8 @@ serve(withRateLimit(async (req) => { headers: { ...corsHeaders, 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId + 'X-Request-ID': tracking.requestId, + 'X-Idempotency-Key': idempotencyKey } } ); @@ -727,6 +881,79 @@ serve(withRateLimit(async (req) => { edgeLogger.info('Processing selective approval', { action: 'approval_start', itemCount: itemIds.length, userId: authenticatedUserId, submissionId }); + // ============================================================================ + // IDEMPOTENCY: Register processing key after validation passes + // ============================================================================ + + edgeLogger.info('Creating idempotency key in processing state', { + action: 'approval_idempotency_create', + idempotencyKey, + submissionId, + itemCount: itemIds.length, + requestId: tracking.requestId + }); + + const { error: createKeyError } = await supabase + .from('submission_idempotency_keys') + .insert({ + idempotency_key: idempotencyKey, + submission_id: submissionId, + moderator_id: authenticatedUserId, + item_ids: itemIds, + status: 'processing', + request_id: tracking.requestId, + trace_id: tracking.traceId + }); + + if (createKeyError) { + // Race condition: another request created the key between our check and now + if (createKeyError.code === '23505') { // unique_violation + edgeLogger.warn('Race condition detected on key creation', { + action: 'approval_idempotency_race', + idempotencyKey, + requestId: tracking.requestId, + postgresCode: createKeyError.code + }); + + const duration = endRequest(tracking); + + return new Response( + JSON.stringify({ + error: 'Duplicate request detected', + code: 'RACE_CONDITION', + message: 'Another request started processing this approval simultaneously. Please retry in a moment.', + requestId: tracking.requestId, + retryAfter: 2 + }), + { + status: 409, + headers: { + ...corsHeaders, + 'Content-Type': 'application/json', + 'X-Request-ID': tracking.requestId, + 'X-Idempotency-Key': idempotencyKey, + 'Retry-After': '2' + } + } + ); + } + + // Other database errors - log but continue (degraded idempotency protection) + edgeLogger.error('Failed to create idempotency key', { + action: 'approval_idempotency_create_error', + error: createKeyError.message, + code: createKeyError.code, + requestId: tracking.requestId + }); + // Don't throw - continue without idempotency protection + } + + edgeLogger.info('Idempotency key registered successfully', { + action: 'approval_idempotency_registered', + idempotencyKey, + requestId: tracking.requestId + }); + // Fetch all items with relational data for the submission const { data: items, error: fetchError } = await supabase .from('submission_items') @@ -1388,24 +1615,116 @@ serve(withRateLimit(async (req) => { const duration = endRequest(tracking); + // ============================================================================ + // IDEMPOTENCY: Update key to 'completed' with cached result + // ============================================================================ + + const successResultData = { + success: true, + results: approvalResults, + submissionStatus: finalStatus + }; + + try { + const { error: updateKeyError } = await supabase + .from('submission_idempotency_keys') + .update({ + status: 'completed', + result_data: successResultData, + completed_at: new Date().toISOString(), + duration_ms: duration + }) + .eq('idempotency_key', idempotencyKey) + .eq('moderator_id', authenticatedUserId); + + if (updateKeyError) { + edgeLogger.error('Failed to update idempotency key to completed', { + action: 'approval_idempotency_complete_error', + idempotencyKey, + error: updateKeyError.message, + requestId: tracking.requestId + }); + // Don't throw - the approval succeeded, just cache failed + } else { + edgeLogger.info('Idempotency key updated to completed', { + action: 'approval_idempotency_completed', + idempotencyKey, + durationMs: duration, + resultSize: JSON.stringify(successResultData).length, + requestId: tracking.requestId + }); + } + } catch (updateError) { + edgeLogger.error('Exception updating idempotency key', { + action: 'approval_idempotency_complete_exception', + error: updateError instanceof Error ? updateError.message : 'Unknown error', + requestId: tracking.requestId + }); + // Continue - don't let cache update failures affect successful approval + } + return new Response( JSON.stringify({ - success: true, - results: approvalResults, - submissionStatus: finalStatus, + ...successResultData, requestId: tracking.requestId }), { headers: { ...corsHeaders, 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId + 'X-Request-ID': tracking.requestId, + 'X-Idempotency-Key': idempotencyKey, + 'X-Cache-Status': 'MISS' } } ); } catch (error: unknown) { const duration = endRequest(tracking); const errorMessage = error instanceof Error ? error.message : 'An unexpected error occurred'; + + // ============================================================================ + // IDEMPOTENCY: Update key to 'failed' to allow retries + // ============================================================================ + + // Only update if idempotencyKey was generated (not for early auth failures) + if (typeof idempotencyKey !== 'undefined') { + try { + const { error: updateKeyError } = await supabase + .from('submission_idempotency_keys') + .update({ + status: 'failed', + error_message: errorMessage, + completed_at: new Date().toISOString(), + duration_ms: duration + }) + .eq('idempotency_key', idempotencyKey) + .eq('moderator_id', authenticatedUserId || ''); + + if (updateKeyError) { + edgeLogger.error('Failed to update idempotency key to failed', { + action: 'approval_idempotency_fail_error', + idempotencyKey, + error: updateKeyError.message, + requestId: tracking.requestId + }); + } else { + edgeLogger.info('Idempotency key updated to failed', { + action: 'approval_idempotency_failed', + idempotencyKey, + errorMessage, + durationMs: duration, + requestId: tracking.requestId + }); + } + } catch (updateError) { + edgeLogger.error('Exception updating idempotency key to failed', { + action: 'approval_idempotency_fail_exception', + error: updateError instanceof Error ? updateError.message : 'Unknown error', + requestId: tracking.requestId + }); + } + } + edgeLogger.error('Approval process failed', { action: 'approval_process_error', error: errorMessage, @@ -1413,6 +1732,7 @@ serve(withRateLimit(async (req) => { requestId: tracking.requestId, duration }); + return createErrorResponse( error, 500,