mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 06:31:14 -05:00
Implement Phase 4: Transaction Resilience
This commit implements Phase 4 of the Sacred Pipeline, focusing on transaction resilience. It introduces: - **Timeout Detection & Recovery**: New utilities in `src/lib/timeoutDetection.ts` to detect, categorize (minor, moderate, critical), and provide recovery strategies for timeouts across various sources (fetch, Supabase, edge functions, database). Includes a `withTimeout` wrapper. - **Lock Auto-Release**: Implemented in `src/lib/moderation/lockAutoRelease.ts` to automatically release submission locks on error, timeout, abandonment, or inactivity. Includes mechanisms for unload events and inactivity monitoring. - **Idempotency Key Lifecycle Management**: A new module `src/lib/idempotencyLifecycle.ts` to track idempotency keys through their states (pending, processing, completed, failed, expired) using IndexedDB. Includes automatic cleanup of expired keys. - **Enhanced Idempotency Helpers**: Updated `src/lib/idempotencyHelpers.ts` to integrate with the new lifecycle management, providing functions to generate, register, validate, and update the status of idempotency keys. - **Transaction Resilience Hook**: A new hook `src/hooks/useTransactionResilience.ts` that combines timeout handling, lock auto-release, and idempotency key management for robust transaction execution. - **Submission Queue Integration**: Updated `src/hooks/useSubmissionQueue.ts` to leverage the new submission queue and idempotency lifecycle functionalities. - **Documentation**: Added `PHASE4_TRANSACTION_RESILIENCE.md` detailing the implemented features and their usage.
This commit is contained in:
@@ -3,8 +3,18 @@
|
||||
*
|
||||
* Provides helper functions for generating and managing idempotency keys
|
||||
* for moderation operations to prevent duplicate requests.
|
||||
*
|
||||
* Integrated with idempotencyLifecycle.ts for full lifecycle tracking.
|
||||
*/
|
||||
|
||||
import {
|
||||
registerIdempotencyKey,
|
||||
updateIdempotencyStatus,
|
||||
getIdempotencyRecord,
|
||||
isIdempotencyKeyValid,
|
||||
type IdempotencyRecord,
|
||||
} from './idempotencyLifecycle';
|
||||
|
||||
/**
|
||||
* Generate a deterministic idempotency key for a moderation action
|
||||
*
|
||||
@@ -88,3 +98,62 @@ export function getRetryAfter(error: unknown): number {
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and register a new idempotency key with lifecycle tracking
|
||||
*
|
||||
* @param action - The moderation action type
|
||||
* @param submissionId - The submission ID
|
||||
* @param itemIds - Array of item IDs being processed
|
||||
* @param userId - The moderator's user ID
|
||||
* @returns Idempotency key and record
|
||||
*/
|
||||
export async function generateAndRegisterKey(
|
||||
action: 'approval' | 'rejection' | 'retry',
|
||||
submissionId: string,
|
||||
itemIds: string[],
|
||||
userId: string
|
||||
): Promise<{ key: string; record: IdempotencyRecord }> {
|
||||
const key = generateIdempotencyKey(action, submissionId, itemIds, userId);
|
||||
const record = await registerIdempotencyKey(key, action, submissionId, itemIds, userId);
|
||||
|
||||
return { key, record };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and mark idempotency key as processing
|
||||
*
|
||||
* @param key - Idempotency key to validate
|
||||
* @returns True if valid and marked as processing
|
||||
*/
|
||||
export async function validateAndStartProcessing(key: string): Promise<boolean> {
|
||||
const isValid = await isIdempotencyKeyValid(key);
|
||||
|
||||
if (!isValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const record = await getIdempotencyRecord(key);
|
||||
|
||||
// Only allow transition from pending to processing
|
||||
if (record?.status !== 'pending') {
|
||||
return false;
|
||||
}
|
||||
|
||||
await updateIdempotencyStatus(key, 'processing');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark idempotency key as completed
|
||||
*/
|
||||
export async function markKeyCompleted(key: string): Promise<void> {
|
||||
await updateIdempotencyStatus(key, 'completed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark idempotency key as failed
|
||||
*/
|
||||
export async function markKeyFailed(key: string, error: string): Promise<void> {
|
||||
await updateIdempotencyStatus(key, 'failed', error);
|
||||
}
|
||||
|
||||
281
src/lib/idempotencyLifecycle.ts
Normal file
281
src/lib/idempotencyLifecycle.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Idempotency Key Lifecycle Management
|
||||
*
|
||||
* Tracks idempotency keys through their lifecycle:
|
||||
* - pending: Key generated, request not yet sent
|
||||
* - processing: Request in progress
|
||||
* - completed: Request succeeded
|
||||
* - failed: Request failed
|
||||
* - expired: Key expired (24h window)
|
||||
*
|
||||
* Part of Sacred Pipeline Phase 4: Transaction Resilience
|
||||
*/
|
||||
|
||||
import { openDB, DBSchema, IDBPDatabase } from 'idb';
|
||||
import { logger } from './logger';
|
||||
|
||||
export type IdempotencyStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'expired';
|
||||
|
||||
export interface IdempotencyRecord {
|
||||
key: string;
|
||||
action: 'approval' | 'rejection' | 'retry';
|
||||
submissionId: string;
|
||||
itemIds: string[];
|
||||
userId: string;
|
||||
status: IdempotencyStatus;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
expiresAt: number;
|
||||
attempts: number;
|
||||
lastError?: string;
|
||||
completedAt?: number;
|
||||
}
|
||||
|
||||
interface IdempotencyDB extends DBSchema {
|
||||
idempotency_keys: {
|
||||
key: string;
|
||||
value: IdempotencyRecord;
|
||||
indexes: {
|
||||
'by-submission': string;
|
||||
'by-status': IdempotencyStatus;
|
||||
'by-expiry': number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const DB_NAME = 'thrillwiki-idempotency';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'idempotency_keys';
|
||||
const KEY_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
let dbInstance: IDBPDatabase<IdempotencyDB> | null = null;
|
||||
|
||||
async function getDB(): Promise<IDBPDatabase<IdempotencyDB>> {
|
||||
if (dbInstance) return dbInstance;
|
||||
|
||||
dbInstance = await openDB<IdempotencyDB>(DB_NAME, DB_VERSION, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
const store = db.createObjectStore(STORE_NAME, { keyPath: 'key' });
|
||||
store.createIndex('by-submission', 'submissionId');
|
||||
store.createIndex('by-status', 'status');
|
||||
store.createIndex('by-expiry', 'expiresAt');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new idempotency key
|
||||
*/
|
||||
export async function registerIdempotencyKey(
|
||||
key: string,
|
||||
action: IdempotencyRecord['action'],
|
||||
submissionId: string,
|
||||
itemIds: string[],
|
||||
userId: string
|
||||
): Promise<IdempotencyRecord> {
|
||||
const db = await getDB();
|
||||
const now = Date.now();
|
||||
|
||||
const record: IdempotencyRecord = {
|
||||
key,
|
||||
action,
|
||||
submissionId,
|
||||
itemIds,
|
||||
userId,
|
||||
status: 'pending',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
expiresAt: now + KEY_TTL_MS,
|
||||
attempts: 0,
|
||||
};
|
||||
|
||||
await db.add(STORE_NAME, record);
|
||||
|
||||
logger.info('[IdempotencyLifecycle] Registered key', {
|
||||
key,
|
||||
action,
|
||||
submissionId,
|
||||
itemCount: itemIds.length,
|
||||
});
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update idempotency key status
|
||||
*/
|
||||
export async function updateIdempotencyStatus(
|
||||
key: string,
|
||||
status: IdempotencyStatus,
|
||||
error?: string
|
||||
): Promise<void> {
|
||||
const db = await getDB();
|
||||
const record = await db.get(STORE_NAME, key);
|
||||
|
||||
if (!record) {
|
||||
logger.warn('[IdempotencyLifecycle] Key not found for update', { key, status });
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
record.status = status;
|
||||
record.updatedAt = now;
|
||||
|
||||
if (status === 'processing') {
|
||||
record.attempts += 1;
|
||||
}
|
||||
|
||||
if (status === 'completed') {
|
||||
record.completedAt = now;
|
||||
}
|
||||
|
||||
if (status === 'failed' && error) {
|
||||
record.lastError = error;
|
||||
}
|
||||
|
||||
await db.put(STORE_NAME, record);
|
||||
|
||||
logger.info('[IdempotencyLifecycle] Updated key status', {
|
||||
key,
|
||||
status,
|
||||
attempts: record.attempts,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get idempotency record by key
|
||||
*/
|
||||
export async function getIdempotencyRecord(key: string): Promise<IdempotencyRecord | null> {
|
||||
const db = await getDB();
|
||||
const record = await db.get(STORE_NAME, key);
|
||||
|
||||
// Check if expired
|
||||
if (record && record.expiresAt < Date.now()) {
|
||||
await updateIdempotencyStatus(key, 'expired');
|
||||
return { ...record, status: 'expired' };
|
||||
}
|
||||
|
||||
return record || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if key exists and is valid
|
||||
*/
|
||||
export async function isIdempotencyKeyValid(key: string): Promise<boolean> {
|
||||
const record = await getIdempotencyRecord(key);
|
||||
|
||||
if (!record) return false;
|
||||
if (record.status === 'expired') return false;
|
||||
if (record.expiresAt < Date.now()) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all keys for a submission
|
||||
*/
|
||||
export async function getSubmissionIdempotencyKeys(
|
||||
submissionId: string
|
||||
): Promise<IdempotencyRecord[]> {
|
||||
const db = await getDB();
|
||||
const index = db.transaction(STORE_NAME).store.index('by-submission');
|
||||
return await index.getAll(submissionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get keys by status
|
||||
*/
|
||||
export async function getIdempotencyKeysByStatus(
|
||||
status: IdempotencyStatus
|
||||
): Promise<IdempotencyRecord[]> {
|
||||
const db = await getDB();
|
||||
const index = db.transaction(STORE_NAME).store.index('by-status');
|
||||
return await index.getAll(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired keys
|
||||
*/
|
||||
export async function cleanupExpiredKeys(): Promise<number> {
|
||||
const db = await getDB();
|
||||
const now = Date.now();
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const index = tx.store.index('by-expiry');
|
||||
|
||||
let deletedCount = 0;
|
||||
|
||||
// Get all expired keys
|
||||
for await (const cursor of index.iterate()) {
|
||||
if (cursor.value.expiresAt < now) {
|
||||
await cursor.delete();
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
await tx.done;
|
||||
|
||||
if (deletedCount > 0) {
|
||||
logger.info('[IdempotencyLifecycle] Cleaned up expired keys', { deletedCount });
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get idempotency statistics
|
||||
*/
|
||||
export async function getIdempotencyStats(): Promise<{
|
||||
total: number;
|
||||
pending: number;
|
||||
processing: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
expired: number;
|
||||
}> {
|
||||
const db = await getDB();
|
||||
const all = await db.getAll(STORE_NAME);
|
||||
const now = Date.now();
|
||||
|
||||
const stats = {
|
||||
total: all.length,
|
||||
pending: 0,
|
||||
processing: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
expired: 0,
|
||||
};
|
||||
|
||||
all.forEach(record => {
|
||||
// Mark as expired if TTL passed
|
||||
if (record.expiresAt < now) {
|
||||
stats.expired++;
|
||||
} else {
|
||||
stats[record.status]++;
|
||||
}
|
||||
});
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-cleanup: Run periodically to remove expired keys
|
||||
*/
|
||||
export function startAutoCleanup(intervalMinutes: number = 60): () => void {
|
||||
const intervalId = setInterval(async () => {
|
||||
try {
|
||||
await cleanupExpiredKeys();
|
||||
} catch (error) {
|
||||
logger.error('[IdempotencyLifecycle] Auto-cleanup failed', { error });
|
||||
}
|
||||
}, intervalMinutes * 60 * 1000);
|
||||
|
||||
// Run immediately on start
|
||||
cleanupExpiredKeys();
|
||||
|
||||
// Return cleanup function
|
||||
return () => clearInterval(intervalId);
|
||||
}
|
||||
236
src/lib/moderation/lockAutoRelease.ts
Normal file
236
src/lib/moderation/lockAutoRelease.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Lock Auto-Release Mechanism
|
||||
*
|
||||
* Automatically releases submission locks when operations fail, timeout,
|
||||
* or are abandoned by moderators. Prevents deadlocks and improves queue flow.
|
||||
*
|
||||
* Part of Sacred Pipeline Phase 4: Transaction Resilience
|
||||
*/
|
||||
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { isTimeoutError } from '@/lib/timeoutDetection';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
|
||||
export interface LockReleaseOptions {
|
||||
submissionId: string;
|
||||
moderatorId: string;
|
||||
reason: 'timeout' | 'error' | 'abandoned' | 'manual';
|
||||
error?: unknown;
|
||||
silent?: boolean; // Don't show toast notification
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a lock on a submission
|
||||
*/
|
||||
export async function releaseLock(options: LockReleaseOptions): Promise<boolean> {
|
||||
const { submissionId, moderatorId, reason, error, silent = false } = options;
|
||||
|
||||
try {
|
||||
// Call Supabase RPC to release lock
|
||||
const { error: releaseError } = await supabase.rpc('release_submission_lock', {
|
||||
submission_id: submissionId,
|
||||
moderator_id: moderatorId,
|
||||
});
|
||||
|
||||
if (releaseError) {
|
||||
logger.error('Failed to release lock', {
|
||||
submissionId,
|
||||
moderatorId,
|
||||
reason,
|
||||
error: releaseError,
|
||||
});
|
||||
|
||||
if (!silent) {
|
||||
toast({
|
||||
title: 'Lock Release Failed',
|
||||
description: 'Failed to release submission lock. It will expire automatically.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info('Lock released', {
|
||||
submissionId,
|
||||
moderatorId,
|
||||
reason,
|
||||
hasError: !!error,
|
||||
});
|
||||
|
||||
if (!silent) {
|
||||
const message = getLockReleaseMessage(reason);
|
||||
toast({
|
||||
title: 'Lock Released',
|
||||
description: message,
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error('Exception while releasing lock', {
|
||||
submissionId,
|
||||
moderatorId,
|
||||
reason,
|
||||
error: err,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-release lock when an operation fails
|
||||
*
|
||||
* @param submissionId - Submission ID
|
||||
* @param moderatorId - Moderator ID
|
||||
* @param error - Error that triggered the release
|
||||
*/
|
||||
export async function autoReleaseLockOnError(
|
||||
submissionId: string,
|
||||
moderatorId: string,
|
||||
error: unknown
|
||||
): Promise<void> {
|
||||
const isTimeout = isTimeoutError(error);
|
||||
|
||||
logger.warn('Auto-releasing lock due to error', {
|
||||
submissionId,
|
||||
moderatorId,
|
||||
isTimeout,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
await releaseLock({
|
||||
submissionId,
|
||||
moderatorId,
|
||||
reason: isTimeout ? 'timeout' : 'error',
|
||||
error,
|
||||
silent: false, // Show notification for transparency
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-release lock when moderator abandons review
|
||||
* Triggered by navigation away, tab close, or inactivity
|
||||
*/
|
||||
export async function autoReleaseLockOnAbandon(
|
||||
submissionId: string,
|
||||
moderatorId: string
|
||||
): Promise<void> {
|
||||
logger.info('Auto-releasing lock due to abandonment', {
|
||||
submissionId,
|
||||
moderatorId,
|
||||
});
|
||||
|
||||
await releaseLock({
|
||||
submissionId,
|
||||
moderatorId,
|
||||
reason: 'abandoned',
|
||||
silent: true, // Silent for better UX
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup auto-release on page unload (user navigates away or closes tab)
|
||||
*/
|
||||
export function setupAutoReleaseOnUnload(
|
||||
submissionId: string,
|
||||
moderatorId: string
|
||||
): () => void {
|
||||
const handleUnload = () => {
|
||||
// Use sendBeacon for reliable unload requests
|
||||
const payload = JSON.stringify({
|
||||
submission_id: submissionId,
|
||||
moderator_id: moderatorId,
|
||||
});
|
||||
|
||||
// Try to call RPC via sendBeacon (more reliable on unload)
|
||||
const url = `${import.meta.env.VITE_SUPABASE_URL}/rest/v1/rpc/release_submission_lock`;
|
||||
const blob = new Blob([payload], { type: 'application/json' });
|
||||
|
||||
navigator.sendBeacon(url, blob);
|
||||
|
||||
logger.info('Scheduled lock release on unload', {
|
||||
submissionId,
|
||||
moderatorId,
|
||||
});
|
||||
};
|
||||
|
||||
// Add listeners
|
||||
window.addEventListener('beforeunload', handleUnload);
|
||||
window.addEventListener('pagehide', handleUnload);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleUnload);
|
||||
window.removeEventListener('pagehide', handleUnload);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitor inactivity and auto-release after timeout
|
||||
*
|
||||
* @param submissionId - Submission ID
|
||||
* @param moderatorId - Moderator ID
|
||||
* @param inactivityMinutes - Minutes of inactivity before release (default: 10)
|
||||
* @returns Cleanup function
|
||||
*/
|
||||
export function setupInactivityAutoRelease(
|
||||
submissionId: string,
|
||||
moderatorId: string,
|
||||
inactivityMinutes: number = 10
|
||||
): () => void {
|
||||
let inactivityTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
const resetTimer = () => {
|
||||
if (inactivityTimer) {
|
||||
clearTimeout(inactivityTimer);
|
||||
}
|
||||
|
||||
inactivityTimer = setTimeout(() => {
|
||||
logger.warn('Inactivity timeout - auto-releasing lock', {
|
||||
submissionId,
|
||||
moderatorId,
|
||||
inactivityMinutes,
|
||||
});
|
||||
|
||||
autoReleaseLockOnAbandon(submissionId, moderatorId);
|
||||
}, inactivityMinutes * 60 * 1000);
|
||||
};
|
||||
|
||||
// Track user activity
|
||||
const activityEvents = ['mousedown', 'keydown', 'scroll', 'touchstart'];
|
||||
activityEvents.forEach(event => {
|
||||
window.addEventListener(event, resetTimer, { passive: true });
|
||||
});
|
||||
|
||||
// Start timer
|
||||
resetTimer();
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
if (inactivityTimer) {
|
||||
clearTimeout(inactivityTimer);
|
||||
}
|
||||
activityEvents.forEach(event => {
|
||||
window.removeEventListener(event, resetTimer);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly lock release message
|
||||
*/
|
||||
function getLockReleaseMessage(reason: LockReleaseOptions['reason']): string {
|
||||
switch (reason) {
|
||||
case 'timeout':
|
||||
return 'Lock released due to timeout. The submission is available for other moderators.';
|
||||
case 'error':
|
||||
return 'Lock released due to an error. You can reclaim it to continue reviewing.';
|
||||
case 'abandoned':
|
||||
return 'Lock released. The submission is back in the queue.';
|
||||
case 'manual':
|
||||
return 'Lock released successfully.';
|
||||
}
|
||||
}
|
||||
216
src/lib/timeoutDetection.ts
Normal file
216
src/lib/timeoutDetection.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Timeout Detection & Recovery
|
||||
*
|
||||
* Detects timeout errors from various sources (fetch, Supabase, edge functions)
|
||||
* and provides recovery strategies.
|
||||
*
|
||||
* Part of Sacred Pipeline Phase 4: Transaction Resilience
|
||||
*/
|
||||
|
||||
import { logger } from './logger';
|
||||
|
||||
export interface TimeoutError extends Error {
|
||||
isTimeout: true;
|
||||
source: 'fetch' | 'supabase' | 'edge-function' | 'database' | 'unknown';
|
||||
originalError?: unknown;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a timeout error
|
||||
*/
|
||||
export function isTimeoutError(error: unknown): boolean {
|
||||
if (!error) return false;
|
||||
|
||||
// Check for AbortController timeout
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for fetch timeout
|
||||
if (error instanceof TypeError && error.message.includes('aborted')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check error message for timeout keywords
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.toLowerCase();
|
||||
return (
|
||||
message.includes('timeout') ||
|
||||
message.includes('timed out') ||
|
||||
message.includes('deadline exceeded') ||
|
||||
message.includes('request aborted') ||
|
||||
message.includes('etimedout')
|
||||
);
|
||||
}
|
||||
|
||||
// Check Supabase/HTTP timeout status codes
|
||||
if (error && typeof error === 'object') {
|
||||
const errorObj = error as { status?: number; code?: string; message?: string };
|
||||
|
||||
// HTTP 408 Request Timeout
|
||||
if (errorObj.status === 408) return true;
|
||||
|
||||
// HTTP 504 Gateway Timeout
|
||||
if (errorObj.status === 504) return true;
|
||||
|
||||
// Supabase timeout codes
|
||||
if (errorObj.code === 'PGRST301') return true; // Connection timeout
|
||||
if (errorObj.code === '57014') return true; // PostgreSQL query cancelled
|
||||
|
||||
// Check message
|
||||
if (errorObj.message?.toLowerCase().includes('timeout')) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an error as a TimeoutError with source information
|
||||
*/
|
||||
export function wrapAsTimeoutError(
|
||||
error: unknown,
|
||||
source: TimeoutError['source'],
|
||||
duration?: number
|
||||
): TimeoutError {
|
||||
const message = error instanceof Error ? error.message : 'Operation timed out';
|
||||
const timeoutError = new Error(message) as TimeoutError;
|
||||
|
||||
timeoutError.name = 'TimeoutError';
|
||||
timeoutError.isTimeout = true;
|
||||
timeoutError.source = source;
|
||||
timeoutError.originalError = error;
|
||||
timeoutError.duration = duration;
|
||||
|
||||
return timeoutError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a function with a timeout wrapper
|
||||
*
|
||||
* @param fn - Function to execute
|
||||
* @param timeoutMs - Timeout in milliseconds
|
||||
* @param source - Source identifier for error tracking
|
||||
* @returns Promise that resolves or rejects with timeout
|
||||
*/
|
||||
export async function withTimeout<T>(
|
||||
fn: () => Promise<T>,
|
||||
timeoutMs: number,
|
||||
source: TimeoutError['source'] = 'unknown'
|
||||
): Promise<T> {
|
||||
const startTime = Date.now();
|
||||
const controller = new AbortController();
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
|
||||
try {
|
||||
// Execute the function with abort signal if supported
|
||||
const result = await fn();
|
||||
clearTimeout(timeoutId);
|
||||
return result;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Check if error is timeout-related
|
||||
if (isTimeoutError(error) || controller.signal.aborted) {
|
||||
const timeoutError = wrapAsTimeoutError(error, source, duration);
|
||||
|
||||
logger.error('Operation timed out', {
|
||||
source,
|
||||
duration,
|
||||
timeoutMs,
|
||||
originalError: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
throw timeoutError;
|
||||
}
|
||||
|
||||
// Re-throw non-timeout errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorize timeout severity for recovery strategy
|
||||
*/
|
||||
export function getTimeoutSeverity(error: TimeoutError): 'minor' | 'moderate' | 'critical' {
|
||||
const { duration, source } = error;
|
||||
|
||||
// No duration means immediate abort - likely user action or critical failure
|
||||
if (!duration) return 'critical';
|
||||
|
||||
// Database/edge function timeouts are more critical
|
||||
if (source === 'database' || source === 'edge-function') {
|
||||
if (duration > 30000) return 'critical'; // >30s
|
||||
if (duration > 10000) return 'moderate'; // >10s
|
||||
return 'minor';
|
||||
}
|
||||
|
||||
// Fetch timeouts
|
||||
if (source === 'fetch') {
|
||||
if (duration > 60000) return 'critical'; // >60s
|
||||
if (duration > 20000) return 'moderate'; // >20s
|
||||
return 'minor';
|
||||
}
|
||||
|
||||
return 'moderate';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recommended retry strategy based on timeout error
|
||||
*/
|
||||
export function getTimeoutRetryStrategy(error: TimeoutError): {
|
||||
shouldRetry: boolean;
|
||||
delayMs: number;
|
||||
maxAttempts: number;
|
||||
increaseTimeout: boolean;
|
||||
} {
|
||||
const severity = getTimeoutSeverity(error);
|
||||
|
||||
switch (severity) {
|
||||
case 'minor':
|
||||
return {
|
||||
shouldRetry: true,
|
||||
delayMs: 1000,
|
||||
maxAttempts: 3,
|
||||
increaseTimeout: false,
|
||||
};
|
||||
|
||||
case 'moderate':
|
||||
return {
|
||||
shouldRetry: true,
|
||||
delayMs: 3000,
|
||||
maxAttempts: 2,
|
||||
increaseTimeout: true, // Increase timeout by 50%
|
||||
};
|
||||
|
||||
case 'critical':
|
||||
return {
|
||||
shouldRetry: false, // Don't auto-retry critical timeouts
|
||||
delayMs: 5000,
|
||||
maxAttempts: 1,
|
||||
increaseTimeout: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User-friendly timeout error message
|
||||
*/
|
||||
export function getTimeoutErrorMessage(error: TimeoutError): string {
|
||||
const severity = getTimeoutSeverity(error);
|
||||
|
||||
switch (severity) {
|
||||
case 'minor':
|
||||
return 'The request took longer than expected. Retrying...';
|
||||
|
||||
case 'moderate':
|
||||
return 'The server is taking longer than usual to respond. Please wait while we retry.';
|
||||
|
||||
case 'critical':
|
||||
return 'The operation timed out. Please check your connection and try again.';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user