feat: Integrate idempotency

Implement idempotency for the process-selective-approval edge function as per the detailed plan.
This commit is contained in:
gpt-engineer-app[bot]
2025-11-06 17:24:21 +00:00
parent 9362479db2
commit 85436b5c1e

View File

@@ -662,7 +662,157 @@ serve(withRateLimit(async (req) => {
edgeLogger.info('AAL2 check passed', { action: 'approval_aal_pass', userId: authenticatedUserId, hasMFA, aal }); 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 // 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; 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: { headers: {
...corsHeaders, ...corsHeaders,
'Content-Type': 'application/json', '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: { headers: {
...corsHeaders, ...corsHeaders,
'Content-Type': 'application/json', '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: { headers: {
...corsHeaders, ...corsHeaders,
'Content-Type': 'application/json', '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: { headers: {
...corsHeaders, ...corsHeaders,
'Content-Type': 'application/json', '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 }); 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 // Fetch all items with relational data for the submission
const { data: items, error: fetchError } = await supabase const { data: items, error: fetchError } = await supabase
.from('submission_items') .from('submission_items')
@@ -1388,24 +1615,116 @@ serve(withRateLimit(async (req) => {
const duration = endRequest(tracking); 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( return new Response(
JSON.stringify({ JSON.stringify({
success: true, ...successResultData,
results: approvalResults,
submissionStatus: finalStatus,
requestId: tracking.requestId requestId: tracking.requestId
}), }),
{ {
headers: { headers: {
...corsHeaders, ...corsHeaders,
'Content-Type': 'application/json', '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) { } catch (error: unknown) {
const duration = endRequest(tracking); const duration = endRequest(tracking);
const errorMessage = error instanceof Error ? error.message : 'An unexpected error occurred'; 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', { edgeLogger.error('Approval process failed', {
action: 'approval_process_error', action: 'approval_process_error',
error: errorMessage, error: errorMessage,
@@ -1413,6 +1732,7 @@ serve(withRateLimit(async (req) => {
requestId: tracking.requestId, requestId: tracking.requestId,
duration duration
}); });
return createErrorResponse( return createErrorResponse(
error, error,
500, 500,