mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-26 12:47:00 -05:00
Improve CORS handling and error logging to fix moderation edge cases: - Add x-idempotency-key to allowed CORS headers and expose explicit POST methods - Extend CORS headers to include Access-Control-Allow-Methods - Update edge function tracing and client error handling to better detect and log CORS/network issues - Enhance error handling utilities to surface CORS-related failures and provide clearer user messages
245 lines
7.5 KiB
TypeScript
245 lines
7.5 KiB
TypeScript
/**
|
|
* Edge Function Request Tracking Wrapper
|
|
*
|
|
* Wraps Supabase function invocations with request tracking for debugging and monitoring.
|
|
* Provides correlation IDs for tracing requests across the system.
|
|
*/
|
|
|
|
import { supabase } from '@/lib/supabaseClient';
|
|
import { trackRequest } from './requestTracking';
|
|
import { getErrorMessage } from './errorHandler';
|
|
import { withRetry, isRetryableError, type RetryOptions } from './retryHelpers';
|
|
import { breadcrumb } from './errorBreadcrumbs';
|
|
import { logger } from './logger';
|
|
|
|
/**
|
|
* Invoke a Supabase edge function with request tracking
|
|
*
|
|
* @param functionName - Name of the edge function to invoke
|
|
* @param payload - Request payload
|
|
* @param userId - User ID for tracking (optional)
|
|
* @param parentRequestId - Parent request ID for chaining (optional)
|
|
* @param traceId - Trace ID for distributed tracing (optional)
|
|
* @param timeout - Request timeout in milliseconds (default: 30000)
|
|
* @param retryOptions - Optional retry configuration
|
|
* @param customHeaders - Custom headers to include in the request (e.g., X-Idempotency-Key)
|
|
* @returns Response data with requestId, status, and tracking info
|
|
*/
|
|
export async function invokeWithTracking<T = any>(
|
|
functionName: string,
|
|
payload: any = {},
|
|
userId?: string,
|
|
parentRequestId?: string,
|
|
traceId?: string,
|
|
timeout: number = 30000,
|
|
retryOptions?: Partial<RetryOptions>,
|
|
customHeaders?: Record<string, string>
|
|
): Promise<{ data: T | null; error: any; requestId: string; duration: number; attempts?: number; status?: number; traceId?: string }> {
|
|
// Configure retry options with defaults
|
|
const effectiveRetryOptions: RetryOptions = {
|
|
maxAttempts: retryOptions?.maxAttempts ?? 3,
|
|
baseDelay: retryOptions?.baseDelay ?? 1000,
|
|
maxDelay: retryOptions?.maxDelay ?? 10000,
|
|
backoffMultiplier: retryOptions?.backoffMultiplier ?? 2,
|
|
jitter: true,
|
|
shouldRetry: isRetryableError,
|
|
onRetry: (attempt, error, delay) => {
|
|
// Log retry attempt to breadcrumbs
|
|
breadcrumb.apiCall(
|
|
`/functions/${functionName}`,
|
|
'POST',
|
|
undefined // status unknown during retry
|
|
);
|
|
|
|
console.info(`Retrying ${functionName} (attempt ${attempt}) after ${delay}ms:`,
|
|
getErrorMessage(error)
|
|
);
|
|
},
|
|
};
|
|
|
|
let attemptCount = 0;
|
|
|
|
try {
|
|
const { result, requestId, duration } = await trackRequest(
|
|
{
|
|
endpoint: `/functions/${functionName}`,
|
|
method: 'POST',
|
|
userId,
|
|
parentRequestId,
|
|
traceId,
|
|
},
|
|
async (context) => {
|
|
return await withRetry(
|
|
async () => {
|
|
attemptCount++;
|
|
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
|
|
// Generate W3C Trace Context header
|
|
const effectiveTraceId = context.traceId || crypto.randomUUID();
|
|
const spanId = crypto.randomUUID();
|
|
const traceparent = `00-${effectiveTraceId}-${spanId}-01`;
|
|
|
|
// Add breadcrumb with trace context
|
|
breadcrumb.apiCall(
|
|
`/functions/${functionName}`,
|
|
'POST',
|
|
undefined
|
|
);
|
|
|
|
try {
|
|
const { data, error } = await supabase.functions.invoke<T>(functionName, {
|
|
body: {
|
|
...payload,
|
|
clientRequestId: context.requestId,
|
|
traceId: effectiveTraceId,
|
|
},
|
|
signal: controller.signal,
|
|
headers: {
|
|
...customHeaders,
|
|
'traceparent': traceparent,
|
|
},
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
if (error) {
|
|
// Enhance error with status and context for retry logic
|
|
const enhancedError = new Error(error.message || 'Edge function error');
|
|
(enhancedError as any).status = error.status;
|
|
(enhancedError as any).context = error.context;
|
|
throw enhancedError;
|
|
}
|
|
|
|
return data;
|
|
} catch (error) {
|
|
clearTimeout(timeoutId);
|
|
throw error;
|
|
}
|
|
},
|
|
effectiveRetryOptions
|
|
);
|
|
}
|
|
);
|
|
|
|
return {
|
|
data: result,
|
|
error: null,
|
|
requestId,
|
|
duration,
|
|
attempts: attemptCount,
|
|
status: 200,
|
|
traceId,
|
|
};
|
|
} catch (error: unknown) {
|
|
// Handle AbortError specifically
|
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
return {
|
|
data: null,
|
|
error: {
|
|
message: `Request timeout: ${functionName} took longer than ${timeout}ms to respond`,
|
|
code: 'TIMEOUT',
|
|
},
|
|
requestId: 'timeout',
|
|
duration: timeout,
|
|
attempts: attemptCount,
|
|
status: 408,
|
|
traceId: undefined,
|
|
};
|
|
}
|
|
|
|
const errorMessage = getErrorMessage(error);
|
|
|
|
// Detect CORS errors specifically
|
|
const isCorsError = errorMessage.toLowerCase().includes('cors') ||
|
|
errorMessage.toLowerCase().includes('cross-origin') ||
|
|
errorMessage.toLowerCase().includes('failed to send') ||
|
|
(error instanceof TypeError && errorMessage.toLowerCase().includes('failed to fetch'));
|
|
|
|
// Enhanced error logging
|
|
logger.error('[EdgeFunctionTracking] Edge function invocation failed', {
|
|
functionName,
|
|
error: errorMessage,
|
|
errorType: isCorsError ? 'CORS/Network' : (error as any)?.name || 'Unknown',
|
|
attempts: attemptCount,
|
|
isCorsError,
|
|
debugHint: isCorsError ? 'Browser blocked request - verify CORS headers allow X-Idempotency-Key or check network connectivity' : undefined,
|
|
status: (error as any)?.status,
|
|
});
|
|
|
|
return {
|
|
data: null,
|
|
error: {
|
|
message: errorMessage,
|
|
status: (error as any)?.status,
|
|
isCorsError,
|
|
},
|
|
requestId: 'unknown',
|
|
duration: 0,
|
|
attempts: attemptCount,
|
|
status: (error as any)?.status,
|
|
traceId: undefined,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Invoke multiple edge functions in parallel with batch tracking
|
|
*
|
|
* Uses a shared trace ID to correlate all operations.
|
|
*
|
|
* @param operations - Array of function invocation configurations
|
|
* @param userId - User ID for tracking
|
|
* @returns Array of results with their request IDs
|
|
*/
|
|
export async function invokeBatchWithTracking<T = any>(
|
|
operations: Array<{
|
|
functionName: string;
|
|
payload: any;
|
|
retryOptions?: Partial<RetryOptions>;
|
|
}>,
|
|
userId?: string
|
|
): Promise<
|
|
Array<{
|
|
functionName: string;
|
|
data: T | null;
|
|
error: any;
|
|
requestId: string;
|
|
duration: number;
|
|
attempts?: number;
|
|
status?: number;
|
|
}>
|
|
> {
|
|
const traceId = crypto.randomUUID();
|
|
|
|
const results = await Promise.allSettled(
|
|
operations.map(async (op) => {
|
|
const result = await invokeWithTracking<T>(
|
|
op.functionName,
|
|
op.payload,
|
|
userId,
|
|
undefined,
|
|
traceId,
|
|
30000,
|
|
op.retryOptions
|
|
);
|
|
return { functionName: op.functionName, ...result };
|
|
})
|
|
);
|
|
|
|
return results.map((result, index) => {
|
|
if (result.status === 'fulfilled') {
|
|
return result.value;
|
|
} else {
|
|
return {
|
|
functionName: operations[index].functionName,
|
|
data: null,
|
|
error: { message: result.reason?.message || 'Unknown error' },
|
|
requestId: 'unknown',
|
|
duration: 0,
|
|
};
|
|
}
|
|
});
|
|
}
|