my pkgs monorepo
at main 310 lines 9.3 kB view raw
1/** 2 * @fileoverview Timeout Helper - Prevent Hanging Operations 3 * 4 * Provides utilities for adding timeouts to promises and operations. 5 * Essential for maintaining responsiveness and preventing hung imports. 6 * 7 * FEATURES: 8 * - Wrap any promise with a timeout 9 * - Configurable timeout duration 10 * - Custom error messages 11 * - Cleanup on timeout 12 * 13 * USAGE: 14 * ```typescript 15 * const result = await withTimeout( 16 * makeApiCall(), 17 * 30000, // 30 seconds 18 * 'API call timed out' 19 * ); 20 * ``` 21 * 22 * @module timeout-helper 23 */ 24 25import { log } from '../logger.js'; 26 27/** 28 * Timeout error class for distinguishing timeout errors from other errors 29 */ 30export class TimeoutError extends Error { 31 constructor(message: string) { 32 super(message); 33 this.name = 'TimeoutError'; 34 35 // Maintains proper stack trace for where our error was thrown 36 if (Error.captureStackTrace) { 37 Error.captureStackTrace(this, TimeoutError); 38 } 39 } 40} 41 42/** 43 * Wrap a promise with a timeout. 44 * If the promise doesn't resolve within the timeout period, it will be rejected. 45 * 46 * ALGORITHM: 47 * 1. Create a timeout promise that rejects after specified duration 48 * 2. Race the original promise against the timeout 49 * 3. If original resolves first: return result 50 * 4. If timeout wins: throw TimeoutError 51 * 52 * IMPORTANT: This doesn't cancel the original promise - it just stops waiting. 53 * The original operation may still complete in the background. 54 * 55 * EXAMPLE: 56 * ```typescript 57 * try { 58 * const data = await withTimeout( 59 * fetch('https://slow-api.com/data'), 60 * 5000, 61 * 'API request timed out after 5s' 62 * ); 63 * } catch (error) { 64 * if (error instanceof TimeoutError) { 65 * console.log('Request took too long'); 66 * } 67 * } 68 * ``` 69 * 70 * @param promise Promise to wrap with timeout 71 * @param timeoutMs Timeout duration in milliseconds 72 * @param errorMessage Custom error message (optional) 73 * @returns Promise that resolves with original result or rejects on timeout 74 * @throws TimeoutError if operation exceeds timeout 75 */ 76export function withTimeout<T>( 77 promise: Promise<T>, 78 timeoutMs: number, 79 errorMessage: string = `Operation timed out after ${timeoutMs}ms` 80): Promise<T> { 81 // Create a promise that rejects after the timeout 82 const timeoutPromise = new Promise<T>((_, reject) => { 83 setTimeout(() => { 84 log.warn(`[TimeoutHelper] ⏱️ Timeout exceeded: ${errorMessage}`); 85 reject(new TimeoutError(errorMessage)); 86 }, timeoutMs); 87 88 // Note: We can't clear this timeout if the original promise resolves first 89 // because we don't have access to the promise's internals. This is a known 90 // limitation of Promise.race(). The timeout will still fire but will be harmless. 91 // For a production system, consider using AbortController for true cancellation. 92 }); 93 94 // Race the original promise against the timeout 95 return Promise.race([promise, timeoutPromise]); 96} 97 98/** 99 * Create a timeout wrapper for a function that will be called multiple times. 100 * Useful for wrapping API clients or frequently-called functions. 101 * 102 * USAGE: 103 * ```typescript 104 * const apiCall = withTimeoutWrapper( 105 * async (endpoint: string) => fetch(endpoint).then(r => r.json()), 106 * 30000 // 30 second timeout for all calls 107 * ); 108 * 109 * // Now all calls have automatic timeout 110 * const data1 = await apiCall('/api/users'); 111 * const data2 = await apiCall('/api/posts'); 112 * ``` 113 * 114 * @param fn Function to wrap with timeout 115 * @param timeoutMs Timeout duration in milliseconds 116 * @param errorMessageFn Function to generate custom error message (optional) 117 * @returns Wrapped function with timeout logic 118 */ 119export function withTimeoutWrapper<TArgs extends any[], TResult>( 120 fn: (...args: TArgs) => Promise<TResult>, 121 timeoutMs: number, 122 errorMessageFn?: (...args: TArgs) => string 123): (...args: TArgs) => Promise<TResult> { 124 return (...args: TArgs) => { 125 const errorMessage = errorMessageFn 126 ? errorMessageFn(...args) 127 : `Function call timed out after ${timeoutMs}ms`; 128 129 return withTimeout(fn(...args), timeoutMs, errorMessage); 130 }; 131} 132 133/** 134 * Execute a function with a timeout. 135 * Convenience function that combines function execution with timeout. 136 * 137 * USAGE: 138 * ```typescript 139 * const result = await executeWithTimeout( 140 * async () => { 141 * const response = await fetch('/api/data'); 142 * return response.json(); 143 * }, 144 * 10000, 145 * 'Data fetch timed out' 146 * ); 147 * ``` 148 * 149 * @param fn Function to execute 150 * @param timeoutMs Timeout duration in milliseconds 151 * @param errorMessage Custom error message (optional) 152 * @returns Promise that resolves with function result or rejects on timeout 153 */ 154export async function executeWithTimeout<T>( 155 fn: () => Promise<T>, 156 timeoutMs: number, 157 errorMessage?: string 158): Promise<T> { 159 return withTimeout(fn(), timeoutMs, errorMessage); 160} 161 162/** 163 * Check if an error is a timeout error. 164 * Useful for error handling and retry logic. 165 * 166 * @param error Error to check 167 * @returns True if error is a timeout error 168 */ 169export function isTimeoutError(error: any): boolean { 170 return error instanceof TimeoutError || 171 error.name === 'TimeoutError' || 172 error.message?.toLowerCase().includes('timeout') || 173 error.code === 'ETIMEDOUT'; 174} 175 176/** 177 * Configuration for timeout with multiple attempts 178 */ 179export interface TimeoutWithRetriesOptions { 180 /** Timeout for each attempt in milliseconds */ 181 timeoutMs: number; 182 183 /** Maximum number of attempts */ 184 maxAttempts?: number; 185 186 /** Delay between attempts in milliseconds (optional) */ 187 retryDelayMs?: number; 188 189 /** Custom error message (optional) */ 190 errorMessage?: string; 191} 192 193/** 194 * Execute a function with timeout and retry logic. 195 * Combines timeout and retry functionality. 196 * 197 * ALGORITHM: 198 * 1. Try to execute function with timeout 199 * 2. If succeeds: return result 200 * 3. If times out and attempts remain: 201 * a. Log timeout 202 * b. Wait for retry delay (if specified) 203 * c. Try again (goto step 1) 204 * 4. If no attempts remain: throw TimeoutError 205 * 206 * EXAMPLE: 207 * ```typescript 208 * // Try 3 times with 10s timeout each 209 * const data = await withTimeoutAndRetries( 210 * () => fetch('/api/data').then(r => r.json()), 211 * { 212 * timeoutMs: 10000, 213 * maxAttempts: 3, 214 * retryDelayMs: 2000 215 * } 216 * ); 217 * ``` 218 * 219 * @param fn Function to execute 220 * @param options Timeout and retry configuration 221 * @returns Promise that resolves with result or rejects if all attempts timeout 222 */ 223export async function withTimeoutAndRetries<T>( 224 fn: () => Promise<T>, 225 options: TimeoutWithRetriesOptions 226): Promise<T> { 227 const { 228 timeoutMs, 229 maxAttempts = 3, 230 retryDelayMs = 0, 231 errorMessage = `Operation timed out after ${maxAttempts} attempts` 232 } = options; 233 234 let lastError: Error | undefined; 235 236 for (let attempt = 1; attempt <= maxAttempts; attempt++) { 237 try { 238 log.debug(`[TimeoutHelper] Attempt ${attempt}/${maxAttempts} with ${timeoutMs}ms timeout`); 239 240 const result = await withTimeout( 241 fn(), 242 timeoutMs, 243 `Attempt ${attempt}/${maxAttempts} timed out after ${timeoutMs}ms` 244 ); 245 246 // Success! 247 if (attempt > 1) { 248 log.info(`[TimeoutHelper] ✅ Succeeded on attempt ${attempt}/${maxAttempts}`); 249 } 250 return result; 251 252 } catch (error: any) { 253 lastError = error; 254 255 // If not a timeout error, rethrow immediately 256 if (!isTimeoutError(error)) { 257 throw error; 258 } 259 260 // If last attempt, rethrow 261 if (attempt === maxAttempts) { 262 log.error(`[TimeoutHelper] ❌ All ${maxAttempts} attempts timed out`); 263 throw new TimeoutError(errorMessage); 264 } 265 266 // Log and retry 267 log.warn(`[TimeoutHelper] ⚠️ Attempt ${attempt}/${maxAttempts} timed out`); 268 if (retryDelayMs > 0) { 269 log.info(`[TimeoutHelper] ⏳ Retrying in ${retryDelayMs}ms...`); 270 await new Promise(resolve => setTimeout(resolve, retryDelayMs)); 271 } 272 } 273 } 274 275 // Should never reach here 276 throw lastError || new TimeoutError(errorMessage); 277} 278 279/** 280 * Calculate recommended timeout based on operation type and network conditions. 281 * Provides sensible defaults for common operations. 282 * 283 * @param operationType Type of operation 284 * @param networkQuality Network quality assessment ('good' | 'medium' | 'poor') 285 * @returns Recommended timeout in milliseconds 286 */ 287export function getRecommendedTimeout( 288 operationType: 'api_call' | 'file_upload' | 'batch_operation' | 'fetch_records', 289 networkQuality: 'good' | 'medium' | 'poor' = 'medium' 290): number { 291 // Base timeouts for different operations (medium network) 292 const baseTimeouts = { 293 api_call: 15000, // 15 seconds 294 file_upload: 60000, // 60 seconds 295 batch_operation: 30000, // 30 seconds 296 fetch_records: 20000, // 20 seconds 297 }; 298 299 // Quality multipliers 300 const qualityMultipliers = { 301 good: 0.7, // Faster on good network 302 medium: 1.0, // Base timeout 303 poor: 2.0, // Double timeout on poor network 304 }; 305 306 const baseTimeout = baseTimeouts[operationType]; 307 const multiplier = qualityMultipliers[networkQuality]; 308 309 return Math.floor(baseTimeout * multiplier); 310}