my pkgs monorepo
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}