NeahStable/lib/utils/fetch-with-timeout.ts
2026-01-17 13:44:45 +01:00

122 lines
3.9 KiB
TypeScript

/**
* Utility function for making HTTP requests with timeout
*
* This is a critical production utility to prevent hanging requests
* that can exhaust server resources.
*/
export interface FetchWithTimeoutOptions extends RequestInit {
timeout?: number; // Timeout in milliseconds (default: 30000)
}
/**
* Fetch with automatic timeout handling
*
* @param url - The URL to fetch
* @param options - Fetch options including optional timeout
* @returns Promise<Response>
* @throws Error if timeout is exceeded or request fails
*
* @example
* ```typescript
* const response = await fetchWithTimeout('https://api.example.com/data', {
* method: 'GET',
* timeout: 10000, // 10 seconds
* headers: { 'Authorization': 'Bearer token' }
* });
* ```
*/
export async function fetchWithTimeout(
url: string,
options: FetchWithTimeoutOptions = {}
): Promise<Response> {
const { timeout = 30000, ...fetchOptions } = options;
// Create AbortController for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, timeout);
try {
const response = await fetch(url, {
...fetchOptions,
signal: controller.signal,
});
clearTimeout(timeoutId);
return response;
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(`Request timeout after ${timeout}ms: ${url}`);
}
throw error;
}
}
/**
* Fetch with timeout and automatic JSON parsing
*
* @param url - The URL to fetch
* @param options - Fetch options including optional timeout
* @returns Promise<T> - Parsed JSON response
*
* @example
* ```typescript
* const data = await fetchJsonWithTimeout('https://api.example.com/data', {
* method: 'GET',
* timeout: 10000,
* });
* ```
*/
export async function fetchJsonWithTimeout<T = any>(
url: string,
options: FetchWithTimeoutOptions = {}
): Promise<T> {
const response = await fetchWithTimeout(url, options);
// Clone response early so we can read it multiple times if needed
const clonedResponse = response.clone();
if (!response.ok) {
const errorText = await clonedResponse.text().catch(() => 'Unknown error');
const contentType = response.headers.get('content-type') || '';
// If it's HTML, provide more context
if (contentType.includes('text/html') || errorText.trim().startsWith('<!DOCTYPE') || errorText.trim().startsWith('<html')) {
throw new Error(`HTTP ${response.status} ${response.statusText}: Server returned HTML page instead of JSON. This usually means authentication failed or URL is incorrect. Preview: ${errorText.substring(0, 300)}`);
}
throw new Error(
`HTTP ${response.status} ${response.statusText}: ${errorText.substring(0, 200)}`
);
}
const contentType = response.headers.get('content-type') || '';
// Check if response is JSON
if (!contentType.includes('application/json')) {
// Read the response text to see what we got
const responseText = await clonedResponse.text().catch(() => 'Unable to read response');
const preview = responseText.substring(0, 500);
// If it looks like HTML, provide a more helpful error
if (responseText.trim().startsWith('<!DOCTYPE') || responseText.trim().startsWith('<html')) {
throw new Error(`Expected JSON response, got text/html. Server may be returning an error page or login page. URL: ${url}. Preview: ${preview}`);
}
throw new Error(`Expected JSON response, got ${contentType || 'unknown'}. URL: ${url}. Preview: ${preview}`);
}
try {
return await response.json();
} catch (jsonError) {
// If JSON parsing fails, try to read the text to see what we got
const responseText = await clonedResponse.text().catch(() => 'Unable to read response');
throw new Error(`Failed to parse JSON response from ${url}. Content: ${responseText.substring(0, 500)}`);
}
}