mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:31:12 -05:00
feat: Integrate idempotency
Implement idempotency for the process-selective-approval edge function as per the detailed plan.
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user