WellNuo/utils/networkErrorRecovery.ts
Sergei 3260119ece Add comprehensive network error handling system
- 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>
2026-02-01 09:29:19 -08:00

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 };