- Add networkErrorRecovery utility with: - Request timeout handling via AbortController - Circuit breaker pattern to prevent cascading failures - Request deduplication for concurrent identical requests - Enhanced fetch with timeout, circuit breaker, and retry support - Add useApiWithErrorHandling hooks: - useApiCall for single API calls with auto error display - useMutation for mutations with optimistic update support - useMultipleApiCalls for parallel API execution - Add ErrorBoundary component: - Catches React errors in component tree - Displays fallback UI with retry option - Supports custom fallback components - withErrorBoundary HOC for easy wrapping - Add comprehensive tests (64 passing tests): - Circuit breaker state transitions - Request deduplication - Timeout detection - Error type classification - Hook behavior and error handling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
587 lines
14 KiB
TypeScript
587 lines
14 KiB
TypeScript
/**
|
|
* Network Error Recovery Utilities
|
|
*
|
|
* Provides robust network error handling with:
|
|
* - Request timeout handling
|
|
* - Circuit breaker pattern to prevent cascading failures
|
|
* - Automatic retry with exponential backoff
|
|
* - Request deduplication for concurrent calls
|
|
*/
|
|
|
|
import { ApiError, ApiResponse } from '@/types';
|
|
import { isOnline } from './networkStatus';
|
|
import { ErrorCodes } from '@/types/errors';
|
|
|
|
// ==================== Configuration ====================
|
|
|
|
/**
|
|
* Default request timeout (30 seconds)
|
|
*/
|
|
export const DEFAULT_REQUEST_TIMEOUT = 30000;
|
|
|
|
/**
|
|
* Short timeout for quick operations (10 seconds)
|
|
*/
|
|
export const SHORT_REQUEST_TIMEOUT = 10000;
|
|
|
|
/**
|
|
* Long timeout for uploads and heavy operations (60 seconds)
|
|
*/
|
|
export const LONG_REQUEST_TIMEOUT = 60000;
|
|
|
|
// ==================== Timeout Handling ====================
|
|
|
|
/**
|
|
* AbortController with timeout
|
|
* Creates an AbortController that automatically aborts after the specified timeout
|
|
*
|
|
* @param timeoutMs - Timeout in milliseconds
|
|
* @returns Object with signal and cleanup function
|
|
*
|
|
* @example
|
|
* const { signal, cleanup } = createTimeoutController(10000);
|
|
* try {
|
|
* const response = await fetch(url, { signal });
|
|
* cleanup();
|
|
* } catch (e) {
|
|
* cleanup();
|
|
* if (e.name === 'AbortError') {
|
|
* // Handle timeout
|
|
* }
|
|
* }
|
|
*/
|
|
export function createTimeoutController(timeoutMs: number = DEFAULT_REQUEST_TIMEOUT): {
|
|
controller: AbortController;
|
|
signal: AbortSignal;
|
|
cleanup: () => void;
|
|
isTimedOut: () => boolean;
|
|
} {
|
|
const controller = new AbortController();
|
|
let timedOut = false;
|
|
|
|
const timeoutId = setTimeout(() => {
|
|
timedOut = true;
|
|
controller.abort();
|
|
}, timeoutMs);
|
|
|
|
const cleanup = () => {
|
|
clearTimeout(timeoutId);
|
|
};
|
|
|
|
return {
|
|
controller,
|
|
signal: controller.signal,
|
|
cleanup,
|
|
isTimedOut: () => timedOut,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Wrap fetch with timeout support
|
|
*
|
|
* @param url - Request URL
|
|
* @param options - Fetch options
|
|
* @param timeoutMs - Timeout in milliseconds
|
|
* @returns Promise with Response
|
|
* @throws Error with code NETWORK_TIMEOUT if request times out
|
|
*
|
|
* @example
|
|
* try {
|
|
* const response = await fetchWithTimeout('https://api.example.com', { method: 'GET' }, 10000);
|
|
* const data = await response.json();
|
|
* } catch (e) {
|
|
* if (e.code === 'NETWORK_TIMEOUT') {
|
|
* // Handle timeout
|
|
* }
|
|
* }
|
|
*/
|
|
export async function fetchWithTimeout(
|
|
url: string,
|
|
options: RequestInit = {},
|
|
timeoutMs: number = DEFAULT_REQUEST_TIMEOUT
|
|
): Promise<Response> {
|
|
const { signal, cleanup, isTimedOut } = createTimeoutController(timeoutMs);
|
|
|
|
try {
|
|
// Merge signals if one was already provided
|
|
const mergedOptions: RequestInit = {
|
|
...options,
|
|
signal: options.signal
|
|
? anySignal([options.signal, signal])
|
|
: signal,
|
|
};
|
|
|
|
const response = await fetch(url, mergedOptions);
|
|
cleanup();
|
|
return response;
|
|
} catch (error) {
|
|
cleanup();
|
|
|
|
// Check if this was a timeout
|
|
if (isTimedOut() || (error instanceof Error && error.name === 'AbortError')) {
|
|
const timeoutError = new Error(`Request timed out after ${timeoutMs}ms`);
|
|
(timeoutError as any).code = ErrorCodes.NETWORK_TIMEOUT;
|
|
(timeoutError as any).name = 'TimeoutError';
|
|
throw timeoutError;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Combine multiple AbortSignals into one
|
|
* The combined signal aborts when any of the input signals abort
|
|
*/
|
|
function anySignal(signals: AbortSignal[]): AbortSignal {
|
|
const controller = new AbortController();
|
|
|
|
for (const signal of signals) {
|
|
if (signal.aborted) {
|
|
controller.abort();
|
|
return controller.signal;
|
|
}
|
|
signal.addEventListener('abort', () => controller.abort(), { once: true });
|
|
}
|
|
|
|
return controller.signal;
|
|
}
|
|
|
|
// ==================== Circuit Breaker ====================
|
|
|
|
/**
|
|
* Circuit breaker states
|
|
*/
|
|
type CircuitState = 'closed' | 'open' | 'half-open';
|
|
|
|
/**
|
|
* Circuit breaker configuration
|
|
*/
|
|
interface CircuitBreakerConfig {
|
|
/** Number of failures before opening circuit */
|
|
failureThreshold: number;
|
|
/** Time to wait before trying again (ms) */
|
|
resetTimeout: number;
|
|
/** Number of successes needed to close circuit from half-open */
|
|
successThreshold: number;
|
|
}
|
|
|
|
/**
|
|
* Default circuit breaker configuration
|
|
*/
|
|
const DEFAULT_CIRCUIT_CONFIG: CircuitBreakerConfig = {
|
|
failureThreshold: 5,
|
|
resetTimeout: 30000, // 30 seconds
|
|
successThreshold: 2,
|
|
};
|
|
|
|
/**
|
|
* Circuit breaker state storage
|
|
*/
|
|
interface CircuitBreakerState {
|
|
state: CircuitState;
|
|
failures: number;
|
|
successes: number;
|
|
lastFailure: number | null;
|
|
lastStateChange: number;
|
|
}
|
|
|
|
/**
|
|
* Circuit breakers by endpoint key
|
|
*/
|
|
const circuits = new Map<string, CircuitBreakerState>();
|
|
|
|
/**
|
|
* Get or create circuit breaker state for a key
|
|
*/
|
|
function getCircuit(key: string): CircuitBreakerState {
|
|
if (!circuits.has(key)) {
|
|
circuits.set(key, {
|
|
state: 'closed',
|
|
failures: 0,
|
|
successes: 0,
|
|
lastFailure: null,
|
|
lastStateChange: Date.now(),
|
|
});
|
|
}
|
|
return circuits.get(key)!;
|
|
}
|
|
|
|
/**
|
|
* Check if circuit is allowing requests
|
|
*/
|
|
export function isCircuitOpen(key: string, config: CircuitBreakerConfig = DEFAULT_CIRCUIT_CONFIG): boolean {
|
|
const circuit = getCircuit(key);
|
|
|
|
if (circuit.state === 'open') {
|
|
// Check if enough time has passed to try again
|
|
const now = Date.now();
|
|
if (now - circuit.lastStateChange >= config.resetTimeout) {
|
|
// Transition to half-open
|
|
circuit.state = 'half-open';
|
|
circuit.successes = 0;
|
|
circuit.lastStateChange = now;
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Record a successful request
|
|
*/
|
|
export function recordSuccess(key: string, config: CircuitBreakerConfig = DEFAULT_CIRCUIT_CONFIG): void {
|
|
const circuit = getCircuit(key);
|
|
|
|
if (circuit.state === 'half-open') {
|
|
circuit.successes++;
|
|
if (circuit.successes >= config.successThreshold) {
|
|
// Close the circuit
|
|
circuit.state = 'closed';
|
|
circuit.failures = 0;
|
|
circuit.successes = 0;
|
|
circuit.lastStateChange = Date.now();
|
|
}
|
|
} else if (circuit.state === 'closed') {
|
|
// Reset failure count on success
|
|
circuit.failures = 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record a failed request
|
|
*/
|
|
export function recordFailure(key: string, config: CircuitBreakerConfig = DEFAULT_CIRCUIT_CONFIG): void {
|
|
const circuit = getCircuit(key);
|
|
const now = Date.now();
|
|
|
|
circuit.failures++;
|
|
circuit.lastFailure = now;
|
|
|
|
if (circuit.state === 'half-open') {
|
|
// Any failure in half-open state opens the circuit
|
|
circuit.state = 'open';
|
|
circuit.lastStateChange = now;
|
|
} else if (circuit.state === 'closed' && circuit.failures >= config.failureThreshold) {
|
|
// Open the circuit
|
|
circuit.state = 'open';
|
|
circuit.lastStateChange = now;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current circuit state for debugging
|
|
*/
|
|
export function getCircuitState(key: string): CircuitBreakerState {
|
|
return getCircuit(key);
|
|
}
|
|
|
|
/**
|
|
* Reset circuit breaker (for testing or manual recovery)
|
|
*/
|
|
export function resetCircuit(key: string): void {
|
|
circuits.delete(key);
|
|
}
|
|
|
|
/**
|
|
* Reset all circuits
|
|
*/
|
|
export function resetAllCircuits(): void {
|
|
circuits.clear();
|
|
}
|
|
|
|
// ==================== Request Deduplication ====================
|
|
|
|
/**
|
|
* In-flight request cache for deduplication
|
|
*/
|
|
const inflightRequests = new Map<string, Promise<any>>();
|
|
|
|
/**
|
|
* Generate a cache key for a request
|
|
*/
|
|
export function generateRequestKey(method: string, url: string, body?: string): string {
|
|
const bodyHash = body ? simpleHash(body) : '';
|
|
return `${method}:${url}:${bodyHash}`;
|
|
}
|
|
|
|
/**
|
|
* Simple hash function for request body
|
|
*/
|
|
function simpleHash(str: string): string {
|
|
let hash = 0;
|
|
for (let i = 0; i < str.length; i++) {
|
|
const char = str.charCodeAt(i);
|
|
hash = ((hash << 5) - hash) + char;
|
|
hash = hash & hash; // Convert to 32bit integer
|
|
}
|
|
return hash.toString(36);
|
|
}
|
|
|
|
/**
|
|
* Deduplicate concurrent identical requests
|
|
* If a request with the same key is already in flight, return the same promise
|
|
*
|
|
* @param key - Unique key for the request
|
|
* @param request - Function that returns the request promise
|
|
* @returns Promise with the result
|
|
*
|
|
* @example
|
|
* // Both calls will use the same underlying request
|
|
* const [result1, result2] = await Promise.all([
|
|
* deduplicateRequest('get-user-1', () => api.getUser(1)),
|
|
* deduplicateRequest('get-user-1', () => api.getUser(1)),
|
|
* ]);
|
|
*/
|
|
export async function deduplicateRequest<T>(
|
|
key: string,
|
|
request: () => Promise<T>
|
|
): Promise<T> {
|
|
// Check if request is already in flight
|
|
const existing = inflightRequests.get(key);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
// Create new request and cache it
|
|
const promise = request().finally(() => {
|
|
// Remove from cache when complete
|
|
inflightRequests.delete(key);
|
|
});
|
|
|
|
inflightRequests.set(key, promise);
|
|
return promise;
|
|
}
|
|
|
|
// ==================== Enhanced Fetch with All Features ====================
|
|
|
|
/**
|
|
* Options for network-aware fetch
|
|
*/
|
|
export interface NetworkAwareFetchOptions extends RequestInit {
|
|
/** Request timeout in milliseconds */
|
|
timeout?: number;
|
|
/** Circuit breaker key (defaults to URL host) */
|
|
circuitKey?: string;
|
|
/** Enable request deduplication for GET requests */
|
|
deduplicate?: boolean;
|
|
/** Number of retry attempts */
|
|
retries?: number;
|
|
/** Base delay between retries in ms */
|
|
retryDelay?: number;
|
|
/** Multiplier for exponential backoff */
|
|
retryBackoff?: number;
|
|
}
|
|
|
|
/**
|
|
* Enhanced fetch with timeout, circuit breaker, and retry support
|
|
*
|
|
* @param url - Request URL
|
|
* @param options - Enhanced fetch options
|
|
* @returns Promise with Response
|
|
*
|
|
* @example
|
|
* const response = await networkAwareFetch('https://api.example.com/data', {
|
|
* method: 'GET',
|
|
* timeout: 10000,
|
|
* retries: 3,
|
|
* deduplicate: true,
|
|
* });
|
|
*/
|
|
export async function networkAwareFetch(
|
|
url: string,
|
|
options: NetworkAwareFetchOptions = {}
|
|
): Promise<Response> {
|
|
const {
|
|
timeout = DEFAULT_REQUEST_TIMEOUT,
|
|
circuitKey = new URL(url).host,
|
|
deduplicate = false,
|
|
retries = 0,
|
|
retryDelay = 1000,
|
|
retryBackoff = 2,
|
|
...fetchOptions
|
|
} = options;
|
|
|
|
// Check if circuit is open
|
|
if (isCircuitOpen(circuitKey)) {
|
|
const error = new Error('Service temporarily unavailable due to repeated failures');
|
|
(error as any).code = ErrorCodes.SERVICE_UNAVAILABLE;
|
|
throw error;
|
|
}
|
|
|
|
// Check network status
|
|
const online = await isOnline();
|
|
if (!online) {
|
|
const error = new Error('No internet connection');
|
|
(error as any).code = ErrorCodes.NETWORK_OFFLINE;
|
|
throw error;
|
|
}
|
|
|
|
// Create the actual fetch function
|
|
const doFetch = async (): Promise<Response> => {
|
|
try {
|
|
const response = await fetchWithTimeout(url, fetchOptions, timeout);
|
|
|
|
// Record success if response is OK
|
|
if (response.ok) {
|
|
recordSuccess(circuitKey);
|
|
} else if (response.status >= 500) {
|
|
// Server errors trigger circuit breaker
|
|
recordFailure(circuitKey);
|
|
}
|
|
|
|
return response;
|
|
} catch (error) {
|
|
recordFailure(circuitKey);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// Handle deduplication for GET requests
|
|
const method = (fetchOptions.method || 'GET').toUpperCase();
|
|
if (deduplicate && method === 'GET') {
|
|
const key = generateRequestKey(method, url);
|
|
return deduplicateRequest(key, doFetch);
|
|
}
|
|
|
|
// Execute with retries if configured
|
|
if (retries > 0) {
|
|
return executeWithRetry(doFetch, retries, retryDelay, retryBackoff);
|
|
}
|
|
|
|
return doFetch();
|
|
}
|
|
|
|
/**
|
|
* Execute a function with retry support
|
|
*/
|
|
async function executeWithRetry<T>(
|
|
fn: () => Promise<T>,
|
|
maxRetries: number,
|
|
baseDelay: number,
|
|
backoffMultiplier: number
|
|
): Promise<T> {
|
|
let lastError: Error | undefined;
|
|
let delay = baseDelay;
|
|
|
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
try {
|
|
return await fn();
|
|
} catch (error) {
|
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
|
|
// Check if error is retryable
|
|
const errorCode = (error as any)?.code;
|
|
const isRetryable =
|
|
errorCode === ErrorCodes.NETWORK_TIMEOUT ||
|
|
errorCode === ErrorCodes.NETWORK_ERROR ||
|
|
errorCode === ErrorCodes.SERVICE_UNAVAILABLE;
|
|
|
|
if (!isRetryable || attempt === maxRetries) {
|
|
throw lastError;
|
|
}
|
|
|
|
// Wait before retry
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
delay *= backoffMultiplier;
|
|
}
|
|
}
|
|
|
|
throw lastError || new Error('Max retries exceeded');
|
|
}
|
|
|
|
// ==================== API Response Helpers ====================
|
|
|
|
/**
|
|
* Create an error response for network issues
|
|
*/
|
|
export function createNetworkErrorResponse<T>(
|
|
code: string,
|
|
message: string
|
|
): ApiResponse<T> {
|
|
return {
|
|
ok: false,
|
|
error: {
|
|
message,
|
|
code,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check if an error is a timeout error
|
|
*/
|
|
export function isTimeoutError(error: unknown): boolean {
|
|
if (error instanceof Error) {
|
|
return (
|
|
(error as any).code === ErrorCodes.NETWORK_TIMEOUT ||
|
|
error.name === 'TimeoutError' ||
|
|
error.name === 'AbortError' ||
|
|
error.message.includes('timeout')
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check if an error is a network error
|
|
*/
|
|
export function isNetworkError(error: unknown): boolean {
|
|
if (error instanceof Error) {
|
|
const code = (error as any).code;
|
|
return (
|
|
code === ErrorCodes.NETWORK_ERROR ||
|
|
code === ErrorCodes.NETWORK_OFFLINE ||
|
|
code === ErrorCodes.NETWORK_TIMEOUT ||
|
|
error.message.toLowerCase().includes('network') ||
|
|
error.message.toLowerCase().includes('fetch')
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get appropriate error code from an error
|
|
*/
|
|
export function getErrorCode(error: unknown): string {
|
|
if (error instanceof Error) {
|
|
const code = (error as any).code;
|
|
if (code) return code;
|
|
|
|
if (isTimeoutError(error)) return ErrorCodes.NETWORK_TIMEOUT;
|
|
if (error.message.toLowerCase().includes('offline')) return ErrorCodes.NETWORK_OFFLINE;
|
|
if (isNetworkError(error)) return ErrorCodes.NETWORK_ERROR;
|
|
}
|
|
return ErrorCodes.UNKNOWN_ERROR;
|
|
}
|
|
|
|
/**
|
|
* Convert any error to an ApiError
|
|
*/
|
|
export function toApiError(error: unknown): ApiError {
|
|
if (error instanceof Error) {
|
|
return {
|
|
message: error.message,
|
|
code: getErrorCode(error),
|
|
};
|
|
}
|
|
|
|
if (typeof error === 'string') {
|
|
return {
|
|
message: error,
|
|
code: ErrorCodes.UNKNOWN_ERROR,
|
|
};
|
|
}
|
|
|
|
return {
|
|
message: 'An unknown error occurred',
|
|
code: ErrorCodes.UNKNOWN_ERROR,
|
|
};
|
|
}
|
|
|
|
// ==================== Exports ====================
|
|
|
|
export type { CircuitBreakerConfig, CircuitBreakerState, CircuitState };
|