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 });
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user