diff --git a/components/errors/ErrorBoundary.tsx b/components/errors/ErrorBoundary.tsx new file mode 100644 index 0000000..f037850 --- /dev/null +++ b/components/errors/ErrorBoundary.tsx @@ -0,0 +1,350 @@ +/** + * ErrorBoundary - React Error Boundary for catching unhandled errors + * + * Catches JavaScript errors anywhere in child component tree, + * logs them, and displays a fallback UI. + */ + +import React, { Component, ErrorInfo, ReactNode } from 'react'; +import { + View, + Text, + TouchableOpacity, + StyleSheet, + ScrollView, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; + +interface ErrorBoundaryProps { + children: ReactNode; + /** Custom fallback component */ + fallback?: ReactNode; + /** Called when an error is caught */ + onError?: (error: Error, errorInfo: ErrorInfo) => void; + /** Reset key - when this changes, the error state is reset */ + resetKey?: string | number; + /** Show error details in development mode */ + showDetails?: boolean; + /** Custom retry handler */ + onRetry?: () => void; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; + errorInfo: ErrorInfo | null; +} + +/** + * ErrorBoundary component - catches React errors and displays fallback UI + * + * @example + * } + * onError={(error, info) => logToService(error, info)} + * > + * + * + */ +export class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { + hasError: false, + error: null, + errorInfo: null, + }; + } + + static getDerivedStateFromError(error: Error): Partial { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + // Log to console in development + if (__DEV__) { + console.error('ErrorBoundary caught an error:', error); + console.error('Component stack:', errorInfo.componentStack); + } + + // Update state with error info + this.setState({ errorInfo }); + + // Call onError callback if provided + this.props.onError?.(error, errorInfo); + + // TODO: Log to error reporting service (Sentry, Bugsnag, etc.) + // errorReportingService.captureException(error, { extra: errorInfo }); + } + + componentDidUpdate(prevProps: ErrorBoundaryProps): void { + // Reset error state when resetKey changes + if ( + this.state.hasError && + prevProps.resetKey !== this.props.resetKey + ) { + this.resetError(); + } + } + + resetError = (): void => { + this.setState({ + hasError: false, + error: null, + errorInfo: null, + }); + }; + + handleRetry = (): void => { + this.resetError(); + this.props.onRetry?.(); + }; + + render(): ReactNode { + if (this.state.hasError) { + // Render custom fallback if provided + if (this.props.fallback) { + return this.props.fallback; + } + + // Render default error UI + return ( + + ); + } + + return this.props.children; + } +} + +// ==================== Default Fallback Component ==================== + +interface DefaultErrorFallbackProps { + error: Error | null; + errorInfo: ErrorInfo | null; + onRetry?: () => void; + showDetails?: boolean; +} + +function DefaultErrorFallback({ + error, + errorInfo, + onRetry, + showDetails = false, +}: DefaultErrorFallbackProps) { + const [detailsExpanded, setDetailsExpanded] = React.useState(false); + + return ( + + + {/* Icon */} + + + + + {/* Title */} + Oops! Something went wrong + + {/* Message */} + + The app encountered an unexpected error. We apologize for the inconvenience. + + + {/* Retry Button */} + {onRetry && ( + + + Try Again + + )} + + {/* Error Details (Development Only) */} + {showDetails && error && ( + + setDetailsExpanded(!detailsExpanded)} + activeOpacity={0.7} + > + + {detailsExpanded ? 'Hide' : 'Show'} Error Details + + + + + {detailsExpanded && ( + + {error.name} + {error.message} + {errorInfo?.componentStack && ( + <> + Component Stack: + + {errorInfo.componentStack} + + + )} + {error.stack && ( + <> + Error Stack: + {error.stack} + + )} + + )} + + )} + + + ); +} + +// ==================== Wrapper HOC ==================== + +/** + * Higher-order component to wrap any component with ErrorBoundary + * + * @example + * const SafeScreen = withErrorBoundary(MyScreen, { + * fallback: , + * onError: logError, + * }); + */ +export function withErrorBoundary

( + WrappedComponent: React.ComponentType

, + errorBoundaryProps?: Omit +) { + const displayName = WrappedComponent.displayName || WrappedComponent.name || 'Component'; + + const ComponentWithErrorBoundary = (props: P) => ( + + + + ); + + ComponentWithErrorBoundary.displayName = `withErrorBoundary(${displayName})`; + + return ComponentWithErrorBoundary; +} + +// ==================== Styles ==================== + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#fff', + padding: 24, + }, + content: { + alignItems: 'center', + maxWidth: 320, + width: '100%', + }, + iconContainer: { + width: 120, + height: 120, + borderRadius: 60, + backgroundColor: '#FEE2E2', + justifyContent: 'center', + alignItems: 'center', + marginBottom: 24, + }, + title: { + fontSize: 20, + fontWeight: '700', + color: '#1F2937', + textAlign: 'center', + marginBottom: 8, + }, + message: { + fontSize: 15, + color: '#6B7280', + textAlign: 'center', + lineHeight: 22, + marginBottom: 24, + }, + retryButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#3B82F6', + paddingVertical: 14, + paddingHorizontal: 24, + borderRadius: 12, + width: '100%', + marginBottom: 24, + }, + buttonIcon: { + marginRight: 8, + }, + retryButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, + detailsContainer: { + width: '100%', + borderTopWidth: 1, + borderTopColor: '#E5E7EB', + paddingTop: 16, + }, + detailsHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 8, + }, + detailsHeaderText: { + fontSize: 13, + color: '#6B7280', + marginRight: 4, + }, + detailsContent: { + maxHeight: 300, + backgroundColor: '#F9FAFB', + borderRadius: 8, + padding: 12, + marginTop: 12, + }, + errorName: { + fontSize: 14, + fontWeight: '600', + color: '#EF4444', + marginBottom: 4, + }, + errorMessage: { + fontSize: 13, + color: '#374151', + marginBottom: 12, + }, + stackTitle: { + fontSize: 12, + fontWeight: '600', + color: '#6B7280', + marginTop: 8, + marginBottom: 4, + }, + stackTrace: { + fontSize: 10, + color: '#9CA3AF', + fontFamily: 'monospace', + lineHeight: 14, + }, +}); + +export default ErrorBoundary; diff --git a/components/errors/__tests__/ErrorBoundary.test.tsx b/components/errors/__tests__/ErrorBoundary.test.tsx new file mode 100644 index 0000000..4889a60 --- /dev/null +++ b/components/errors/__tests__/ErrorBoundary.test.tsx @@ -0,0 +1,218 @@ +/** + * Tests for ErrorBoundary component + */ + +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { Text, View } from 'react-native'; +import { ErrorBoundary, withErrorBoundary } from '../ErrorBoundary'; + +// Component that throws an error +function ThrowingComponent({ shouldThrow = true }: { shouldThrow?: boolean }) { + if (shouldThrow) { + throw new Error('Test error'); + } + return Content rendered; +} + +// Suppress console errors during tests +const originalConsoleError = console.error; +beforeAll(() => { + console.error = jest.fn(); +}); +afterAll(() => { + console.error = originalConsoleError; +}); + +describe('ErrorBoundary', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render children when no error occurs', () => { + const { getByText } = render( + + Hello World + + ); + + expect(getByText('Hello World')).toBeTruthy(); + }); + + it('should render fallback UI when error occurs', () => { + const { getByText } = render( + + + + ); + + expect(getByText('Oops! Something went wrong')).toBeTruthy(); + expect(getByText(/The app encountered an unexpected error/)).toBeTruthy(); + }); + + it('should render custom fallback when provided', () => { + const { getByText } = render( + Custom Error UI}> + + + ); + + expect(getByText('Custom Error UI')).toBeTruthy(); + }); + + it('should call onError callback when error occurs', () => { + const onError = jest.fn(); + + render( + + + + ); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + componentStack: expect.any(String), + }) + ); + }); + + it('should provide retry functionality', () => { + const onRetry = jest.fn(); + + const { getByText } = render( + + + + ); + + const tryAgainButton = getByText('Try Again'); + fireEvent.press(tryAgainButton); + + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('should reset error state when retry is pressed', () => { + let shouldThrow = true; + + const TestComponent = () => { + if (shouldThrow) { + throw new Error('Test'); + } + return Recovered; + }; + + const { getByText, queryByText, rerender } = render( + { shouldThrow = false; }}> + + + ); + + // Error is shown + expect(getByText('Oops! Something went wrong')).toBeTruthy(); + + // Press retry + fireEvent.press(getByText('Try Again')); + + // Re-render to see if it recovers + rerender( + + + + ); + + expect(getByText('Recovered')).toBeTruthy(); + expect(queryByText('Oops! Something went wrong')).toBeNull(); + }); + + it('should reset error state when resetKey changes', () => { + const { rerender, getByText, queryByText } = render( + + + + ); + + expect(getByText('Oops! Something went wrong')).toBeTruthy(); + + // Change resetKey and provide non-throwing child + rerender( + + + + ); + + expect(getByText('Content rendered')).toBeTruthy(); + expect(queryByText('Oops! Something went wrong')).toBeNull(); + }); + + it('should show error details in development mode', () => { + const { getByText, queryByText } = render( + + + + ); + + // Details toggle should be present + expect(getByText(/Error Details/)).toBeTruthy(); + + // Click to expand + fireEvent.press(getByText(/Error Details/)); + + // Error message should be visible + expect(getByText('Test error')).toBeTruthy(); + }); + + it('should hide error details when showDetails is false', () => { + const { queryByText } = render( + + + + ); + + expect(queryByText(/Error Details/)).toBeNull(); + }); +}); + +describe('withErrorBoundary HOC', () => { + it('should wrap component with error boundary', () => { + const MyComponent = () => My Component; + const WrappedComponent = withErrorBoundary(MyComponent); + + const { getByText } = render(); + + expect(getByText('My Component')).toBeTruthy(); + }); + + it('should catch errors from wrapped component', () => { + const onError = jest.fn(); + const WrappedComponent = withErrorBoundary(ThrowingComponent, { onError }); + + const { getByText } = render(); + + expect(getByText('Oops! Something went wrong')).toBeTruthy(); + expect(onError).toHaveBeenCalled(); + }); + + it('should pass props to wrapped component', () => { + interface Props { + message: string; + } + + const MyComponent = ({ message }: Props) => {message}; + const WrappedComponent = withErrorBoundary(MyComponent); + + const { getByText } = render(); + + expect(getByText('Hello Props')).toBeTruthy(); + }); + + it('should set correct displayName', () => { + const MyComponent = () => Test; + MyComponent.displayName = 'MyComponent'; + + const WrappedComponent = withErrorBoundary(MyComponent); + + expect(WrappedComponent.displayName).toBe('withErrorBoundary(MyComponent)'); + }); +}); diff --git a/components/errors/index.ts b/components/errors/index.ts index fee7b96..fd6957e 100644 --- a/components/errors/index.ts +++ b/components/errors/index.ts @@ -2,6 +2,7 @@ * Error Components Export */ +export { ErrorBoundary, withErrorBoundary } from './ErrorBoundary'; export { ErrorToast } from './ErrorToast'; export { FieldError, FieldErrorSummary } from './FieldError'; export { FullScreenError, EmptyState, OfflineState } from './FullScreenError'; diff --git a/hooks/__tests__/useApiWithErrorHandling.test.ts b/hooks/__tests__/useApiWithErrorHandling.test.ts new file mode 100644 index 0000000..8ee0c81 --- /dev/null +++ b/hooks/__tests__/useApiWithErrorHandling.test.ts @@ -0,0 +1,406 @@ +/** + * Tests for useApiWithErrorHandling hooks + */ + +import { renderHook, act, waitFor } from '@testing-library/react-native'; +import { useApiCall, useMutation, useMultipleApiCalls } from '../useApiWithErrorHandling'; +import { ApiResponse } from '@/types'; +import { AppError } from '@/types/errors'; + +// Mock ErrorContext +const mockShowError = jest.fn(); +jest.mock('@/contexts/ErrorContext', () => ({ + useShowErrorSafe: () => mockShowError, +})); + +// Mock errorHandler +jest.mock('@/services/errorHandler', () => ({ + createErrorFromApi: jest.fn((error) => ({ + message: error.message, + code: error.code || 'API_ERROR', + severity: 'error', + category: 'unknown', + retry: { isRetryable: false }, + userMessage: error.message, + timestamp: new Date(), + errorId: 'test-error-id', + })), + createNetworkError: jest.fn((message, isTimeout = false) => ({ + message, + code: isTimeout ? 'NETWORK_TIMEOUT' : 'NETWORK_ERROR', + severity: 'warning', + category: 'network', + retry: { isRetryable: true }, + userMessage: message, + timestamp: new Date(), + errorId: 'test-network-error-id', + })), +})); + +// Mock networkErrorRecovery +jest.mock('@/utils/networkErrorRecovery', () => ({ + isNetworkError: jest.fn(() => false), + isTimeoutError: jest.fn(() => false), + toApiError: jest.fn((error) => ({ + message: error instanceof Error ? error.message : String(error), + code: 'UNKNOWN_ERROR', + })), +})); + +describe('useApiCall', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should start with initial state', () => { + const mockApiCall = jest.fn(); + const { result } = renderHook(() => useApiCall(mockApiCall)); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + expect(result.current.data).toBe(null); + }); + + it('should handle successful API call', async () => { + const mockData = { id: 1, name: 'Test' }; + const mockApiCall = jest.fn().mockResolvedValue({ + ok: true, + data: mockData, + } as ApiResponse); + + const onSuccess = jest.fn(); + const { result } = renderHook(() => + useApiCall(mockApiCall, { onSuccess }) + ); + + await act(async () => { + await result.current.execute(); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toEqual(mockData); + expect(result.current.error).toBe(null); + expect(onSuccess).toHaveBeenCalled(); + expect(mockShowError).not.toHaveBeenCalled(); + }); + + it('should handle API error response', async () => { + const mockApiCall = jest.fn().mockResolvedValue({ + ok: false, + error: { message: 'Not found', code: 'NOT_FOUND' }, + } as ApiResponse); + + const onError = jest.fn(); + const { result } = renderHook(() => + useApiCall(mockApiCall, { onError }) + ); + + await act(async () => { + await result.current.execute(); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBe(null); + expect(result.current.error).not.toBe(null); + expect(result.current.error?.message).toBe('Not found'); + expect(onError).toHaveBeenCalled(); + expect(mockShowError).toHaveBeenCalled(); + }); + + it('should not show error when showError is false', async () => { + const mockApiCall = jest.fn().mockResolvedValue({ + ok: false, + error: { message: 'Error' }, + } as ApiResponse); + + const { result } = renderHook(() => + useApiCall(mockApiCall, { showError: false }) + ); + + await act(async () => { + await result.current.execute(); + }); + + expect(result.current.error).not.toBe(null); + expect(mockShowError).not.toHaveBeenCalled(); + }); + + it('should handle thrown exceptions', async () => { + const mockApiCall = jest.fn().mockRejectedValue(new Error('Network failed')); + + const { result } = renderHook(() => useApiCall(mockApiCall)); + + await act(async () => { + await result.current.execute(); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).not.toBe(null); + expect(mockShowError).toHaveBeenCalled(); + }); + + it('should reset state', async () => { + const mockData = { id: 1 }; + const mockApiCall = jest.fn().mockResolvedValue({ + ok: true, + data: mockData, + }); + + const { result } = renderHook(() => useApiCall(mockApiCall)); + + await act(async () => { + await result.current.execute(); + }); + + expect(result.current.data).toEqual(mockData); + + act(() => { + result.current.reset(); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + expect(result.current.data).toBe(null); + }); + + it('should retry last call', async () => { + let callCount = 0; + const mockApiCall = jest.fn().mockImplementation(async () => { + callCount++; + if (callCount === 1) { + return { ok: false, error: { message: 'First failed' } }; + } + return { ok: true, data: { success: true } }; + }); + + const { result } = renderHook(() => useApiCall(mockApiCall)); + + // First call fails + await act(async () => { + await result.current.execute(); + }); + + expect(result.current.error).not.toBe(null); + + // Retry succeeds + await act(async () => { + await result.current.retry(); + }); + + expect(result.current.data).toEqual({ success: true }); + expect(result.current.error).toBe(null); + }); + + it('should set loading state during execution', async () => { + let resolvePromise: (value: ApiResponse) => void; + const mockApiCall = jest.fn().mockReturnValue( + new Promise(resolve => { + resolvePromise = resolve; + }) + ); + + const { result } = renderHook(() => useApiCall(mockApiCall)); + + // Start execution + let executePromise: Promise; + act(() => { + executePromise = result.current.execute(); + }); + + expect(result.current.isLoading).toBe(true); + + // Resolve the promise + await act(async () => { + resolvePromise!({ ok: true, data: {} }); + await executePromise; + }); + + expect(result.current.isLoading).toBe(false); + }); +}); + +describe('useMutation', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should start with initial state', () => { + const mockMutationFn = jest.fn(); + const { result } = renderHook(() => useMutation(mockMutationFn)); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + expect(result.current.data).toBe(null); + expect(result.current.isError).toBe(false); + expect(result.current.isSuccess).toBe(false); + }); + + it('should handle successful mutation', async () => { + const mockData = { id: 1, created: true }; + const mockMutationFn = jest.fn().mockResolvedValue({ + ok: true, + data: mockData, + }); + + const onMutate = jest.fn(); + const onSuccess = jest.fn(); + const onSettled = jest.fn(); + + const { result } = renderHook(() => + useMutation(mockMutationFn, { onMutate, onSuccess, onSettled }) + ); + + await act(async () => { + await result.current.mutate({ name: 'Test' }); + }); + + expect(onMutate).toHaveBeenCalledWith({ name: 'Test' }); + expect(onSuccess).toHaveBeenCalledWith(mockData, { name: 'Test' }); + expect(onSettled).toHaveBeenCalledWith(mockData, null, { name: 'Test' }); + expect(result.current.data).toEqual(mockData); + expect(result.current.isSuccess).toBe(true); + expect(result.current.isError).toBe(false); + }); + + it('should handle mutation error', async () => { + const mockMutationFn = jest.fn().mockResolvedValue({ + ok: false, + error: { message: 'Mutation failed' }, + }); + + const onError = jest.fn(); + const onSettled = jest.fn(); + + const { result } = renderHook(() => + useMutation(mockMutationFn, { onError, onSettled }) + ); + + await act(async () => { + await result.current.mutate({ name: 'Test' }); + }); + + expect(onError).toHaveBeenCalled(); + expect(onSettled).toHaveBeenCalled(); + expect(result.current.isError).toBe(true); + expect(result.current.isSuccess).toBe(false); + }); + + it('should call onMutate for optimistic updates', async () => { + const callOrder: string[] = []; + const mockMutationFn = jest.fn().mockImplementation(async () => { + callOrder.push('mutation'); + return { ok: true, data: {} }; + }); + const onMutate = jest.fn().mockImplementation(async () => { + callOrder.push('onMutate'); + }); + + const { result } = renderHook(() => + useMutation(mockMutationFn, { onMutate }) + ); + + await act(async () => { + await result.current.mutate({ id: 1 }); + }); + + expect(onMutate).toHaveBeenCalledWith({ id: 1 }); + expect(callOrder).toEqual(['onMutate', 'mutation']); + }); + + it('should reset state', async () => { + const mockMutationFn = jest.fn().mockResolvedValue({ + ok: true, + data: { id: 1 }, + }); + + const { result } = renderHook(() => useMutation(mockMutationFn)); + + await act(async () => { + await result.current.mutate({}); + }); + + expect(result.current.data).not.toBe(null); + + act(() => { + result.current.reset(); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + expect(result.current.data).toBe(null); + }); +}); + +describe('useMultipleApiCalls', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should execute multiple calls in parallel', async () => { + const call1 = jest.fn().mockResolvedValue({ ok: true, data: { id: 1 } }); + const call2 = jest.fn().mockResolvedValue({ ok: true, data: { id: 2 } }); + const call3 = jest.fn().mockResolvedValue({ ok: true, data: { id: 3 } }); + + const { result } = renderHook(() => useMultipleApiCalls()); + + let results: any[]; + await act(async () => { + results = await result.current.executeAll([call1, call2, call3]); + }); + + expect(results!).toHaveLength(3); + expect(results![0].data).toEqual({ id: 1 }); + expect(results![1].data).toEqual({ id: 2 }); + expect(results![2].data).toEqual({ id: 3 }); + expect(result.current.hasErrors).toBe(false); + }); + + it('should collect errors from failed calls', async () => { + const call1 = jest.fn().mockResolvedValue({ ok: true, data: { id: 1 } }); + const call2 = jest.fn().mockResolvedValue({ ok: false, error: { message: 'Failed' } }); + const call3 = jest.fn().mockResolvedValue({ ok: true, data: { id: 3 } }); + + const onError = jest.fn(); + const { result } = renderHook(() => useMultipleApiCalls()); + + await act(async () => { + await result.current.executeAll([call1, call2, call3], { onError }); + }); + + expect(result.current.hasErrors).toBe(true); + expect(result.current.errors).toHaveLength(1); + expect(onError).toHaveBeenCalled(); + }); + + it('should reset state', async () => { + const call1 = jest.fn().mockResolvedValue({ ok: false, error: { message: 'Error' } }); + + const { result } = renderHook(() => useMultipleApiCalls()); + + await act(async () => { + await result.current.executeAll([call1]); + }); + + expect(result.current.hasErrors).toBe(true); + + act(() => { + result.current.reset(); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.errors).toEqual([]); + expect(result.current.hasErrors).toBe(false); + }); + + it('should handle thrown exceptions', async () => { + const call1 = jest.fn().mockRejectedValue(new Error('Exception')); + + const { result } = renderHook(() => useMultipleApiCalls()); + + await act(async () => { + await result.current.executeAll([call1]); + }); + + expect(result.current.hasErrors).toBe(true); + }); +}); diff --git a/hooks/useApiWithErrorHandling.ts b/hooks/useApiWithErrorHandling.ts new file mode 100644 index 0000000..ec18f49 --- /dev/null +++ b/hooks/useApiWithErrorHandling.ts @@ -0,0 +1,371 @@ +/** + * useApiWithErrorHandling - Automatic API Error Integration + * + * Provides a wrapper hook that automatically: + * - Shows errors in ErrorContext + * - Handles network errors gracefully + * - Provides retry functionality + * - Tracks loading states + */ + +import { useCallback, useState, useRef } from 'react'; +import { ApiResponse, ApiError } from '@/types'; +import { AppError } from '@/types/errors'; +import { createErrorFromApi, createNetworkError } from '@/services/errorHandler'; +import { useShowErrorSafe } from '@/contexts/ErrorContext'; +import { isNetworkError, isTimeoutError, toApiError } from '@/utils/networkErrorRecovery'; + +/** + * Options for API call execution + */ +interface ApiCallOptions { + /** Show error in ErrorContext (default: true) */ + showError?: boolean; + /** Auto-dismiss error after timeout (default: based on severity) */ + autoDismiss?: boolean; + /** Custom error message override */ + errorMessage?: string; + /** Context data for error logging */ + context?: Record; + /** Callback on retry */ + onRetry?: () => void; + /** Callback on error dismiss */ + onDismiss?: () => void; + /** Callback on success */ + onSuccess?: () => void; + /** Callback on error */ + onError?: (error: AppError) => void; +} + +/** + * Result from useApiCall hook + */ +interface UseApiCallResult { + /** Execute the API call */ + execute: () => Promise>; + /** Loading state */ + isLoading: boolean; + /** Error from last call (if any) */ + error: AppError | null; + /** Data from last successful call */ + data: T | null; + /** Reset state */ + reset: () => void; + /** Retry last call */ + retry: () => Promise>; +} + +/** + * Hook for executing API calls with automatic error handling + * + * @param apiCall - Function that returns ApiResponse + * @param options - Configuration options + * @returns Object with execute function, state, and utilities + * + * @example + * function MyComponent() { + * const { execute, isLoading, error, data } = useApiCall( + * () => api.getBeneficiary(id), + * { onSuccess: () => console.log('Loaded!') } + * ); + * + * useEffect(() => { + * execute(); + * }, []); + * + * if (isLoading) return ; + * if (error) return ; + * return ; + * } + */ +export function useApiCall( + apiCall: () => Promise>, + options: ApiCallOptions = {} +): UseApiCallResult { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + const showError = useShowErrorSafe(); + const lastCallRef = useRef(apiCall); + + // Update ref when apiCall changes + lastCallRef.current = apiCall; + + const execute = useCallback(async (): Promise> => { + setIsLoading(true); + setError(null); + + try { + const response = await lastCallRef.current(); + + if (response.ok && response.data !== undefined) { + setData(response.data); + options.onSuccess?.(); + return response; + } + + // Handle API error + const apiError = response.error || { message: options.errorMessage || 'Request failed' }; + const appError = createErrorFromApi(apiError, options.context); + + setError(appError); + options.onError?.(appError); + + // Show error in ErrorContext if enabled + if (options.showError !== false && showError) { + showError(appError, { + autoDismiss: options.autoDismiss, + onRetry: options.onRetry, + onDismiss: options.onDismiss, + }); + } + + return response; + } catch (err) { + // Handle exceptions (network errors, etc.) + let appError: AppError; + + if (isTimeoutError(err)) { + appError = createNetworkError('Request timed out. Please try again.', true); + } else if (isNetworkError(err)) { + appError = createNetworkError('Network error. Please check your connection.'); + } else { + const apiError = toApiError(err); + appError = createErrorFromApi(apiError, options.context); + } + + setError(appError); + options.onError?.(appError); + + // Show error in ErrorContext if enabled + if (options.showError !== false && showError) { + showError(appError, { + autoDismiss: options.autoDismiss ?? true, + onRetry: options.onRetry, + onDismiss: options.onDismiss, + }); + } + + return { ok: false, error: toApiError(err) }; + } finally { + setIsLoading(false); + } + }, [options, showError]); + + const reset = useCallback(() => { + setIsLoading(false); + setError(null); + setData(null); + }, []); + + const retry = useCallback(() => { + return execute(); + }, [execute]); + + return { + execute, + isLoading, + error, + data, + reset, + retry, + }; +} + +/** + * Hook for executing multiple API calls with unified error handling + * + * @example + * const { executeAll, isLoading, errors } = useMultipleApiCalls(); + * + * const results = await executeAll([ + * () => api.getBeneficiaries(), + * () => api.getProfile(), + * ]); + */ +export function useMultipleApiCalls() { + const [isLoading, setIsLoading] = useState(false); + const [errors, setErrors] = useState([]); + const showError = useShowErrorSafe(); + + const executeAll = useCallback( + async ( + calls: Array<() => Promise>>, + options: ApiCallOptions = {} + ): Promise>> => { + setIsLoading(true); + setErrors([]); + + try { + const results = await Promise.all(calls.map(call => call())); + const newErrors: AppError[] = []; + + results.forEach((result, index) => { + if (!result.ok && result.error) { + const appError = createErrorFromApi(result.error, { + ...options.context, + callIndex: index, + }); + newErrors.push(appError); + } + }); + + if (newErrors.length > 0) { + setErrors(newErrors); + + // Show first error in ErrorContext + if (options.showError !== false && showError && newErrors[0]) { + showError(newErrors[0], { + autoDismiss: options.autoDismiss, + }); + } + + options.onError?.(newErrors[0]); + } else { + options.onSuccess?.(); + } + + return results; + } catch (err) { + const appError = createNetworkError( + err instanceof Error ? err.message : 'Multiple requests failed' + ); + setErrors([appError]); + + if (options.showError !== false && showError) { + showError(appError, { autoDismiss: true }); + } + + options.onError?.(appError); + return calls.map(() => ({ ok: false, error: toApiError(err) })); + } finally { + setIsLoading(false); + } + }, + [showError] + ); + + const reset = useCallback(() => { + setIsLoading(false); + setErrors([]); + }, []); + + return { + executeAll, + isLoading, + errors, + hasErrors: errors.length > 0, + reset, + }; +} + +/** + * Hook for mutation operations (create, update, delete) + * Provides optimistic updates and rollback on error + * + * @example + * const { mutate, isLoading } = useMutation( + * (name: string) => api.updateBeneficiary(id, { name }), + * { + * onSuccess: () => toast.success('Updated!'), + * onError: (error) => console.error(error), + * } + * ); + * + * const handleSave = async () => { + * await mutate('New Name'); + * }; + */ +export function useMutation( + mutationFn: (variables: TVariables) => Promise>, + options: ApiCallOptions & { + /** Called before mutation starts */ + onMutate?: (variables: TVariables) => void | Promise; + /** Called when mutation succeeds */ + onSuccess?: (data: TData, variables: TVariables) => void | Promise; + /** Called when mutation fails */ + onError?: (error: AppError, variables: TVariables) => void | Promise; + /** Called after mutation completes (success or error) */ + onSettled?: (data: TData | undefined, error: AppError | null, variables: TVariables) => void | Promise; + } = {} +) { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + const showError = useShowErrorSafe(); + + const mutate = useCallback( + async (variables: TVariables): Promise> => { + setIsLoading(true); + setError(null); + + try { + // Call onMutate for optimistic updates + await options.onMutate?.(variables); + + const response = await mutationFn(variables); + + if (response.ok && response.data !== undefined) { + setData(response.data); + await options.onSuccess?.(response.data, variables); + await options.onSettled?.(response.data, null, variables); + return response; + } + + // Handle error + const apiError = response.error || { message: 'Mutation failed' }; + const appError = createErrorFromApi(apiError, options.context); + + setError(appError); + await options.onError?.(appError, variables); + await options.onSettled?.(undefined, appError, variables); + + // Show error if enabled + if (options.showError !== false && showError) { + showError(appError, { + autoDismiss: options.autoDismiss, + onRetry: () => mutate(variables), + }); + } + + return response; + } catch (err) { + const appError = createNetworkError( + err instanceof Error ? err.message : 'Mutation failed' + ); + + setError(appError); + await options.onError?.(appError, variables); + await options.onSettled?.(undefined, appError, variables); + + if (options.showError !== false && showError) { + showError(appError, { autoDismiss: true }); + } + + return { ok: false, error: toApiError(err) }; + } finally { + setIsLoading(false); + } + }, + [mutationFn, options, showError] + ); + + const reset = useCallback(() => { + setIsLoading(false); + setError(null); + setData(null); + }, []); + + return { + mutate, + isLoading, + error, + data, + reset, + isError: error !== null, + isSuccess: data !== null && error === null, + }; +} + +// Export all hooks +export default useApiCall; diff --git a/utils/__tests__/networkErrorRecovery.test.ts b/utils/__tests__/networkErrorRecovery.test.ts new file mode 100644 index 0000000..18dadab --- /dev/null +++ b/utils/__tests__/networkErrorRecovery.test.ts @@ -0,0 +1,452 @@ +/** + * Tests for Network Error Recovery Utilities + */ + +import { + createTimeoutController, + isCircuitOpen, + recordSuccess, + recordFailure, + resetCircuit, + resetAllCircuits, + generateRequestKey, + deduplicateRequest, + isTimeoutError, + isNetworkError, + getErrorCode, + toApiError, + createNetworkErrorResponse, + DEFAULT_REQUEST_TIMEOUT, + SHORT_REQUEST_TIMEOUT, + LONG_REQUEST_TIMEOUT, +} from '../networkErrorRecovery'; +import { ErrorCodes } from '@/types/errors'; + +// Mock isOnline to always return true for tests +jest.mock('../networkStatus', () => ({ + isOnline: jest.fn().mockResolvedValue(true), + getNetworkStatus: jest.fn().mockResolvedValue('online'), +})); + +describe('networkErrorRecovery', () => { + beforeEach(() => { + resetAllCircuits(); + jest.clearAllMocks(); + }); + + describe('Timeout Constants', () => { + it('should have correct default timeout values', () => { + expect(DEFAULT_REQUEST_TIMEOUT).toBe(30000); + expect(SHORT_REQUEST_TIMEOUT).toBe(10000); + expect(LONG_REQUEST_TIMEOUT).toBe(60000); + }); + }); + + describe('createTimeoutController', () => { + it('should create an AbortController with signal', () => { + const { controller, signal, cleanup } = createTimeoutController(1000); + + expect(controller).toBeInstanceOf(AbortController); + expect(signal).toBe(controller.signal); + expect(typeof cleanup).toBe('function'); + + cleanup(); // Clean up to prevent timer leaks + }); + + it('should report not timed out initially', () => { + const { isTimedOut, cleanup } = createTimeoutController(1000); + + expect(isTimedOut()).toBe(false); + + cleanup(); + }); + + it('should abort after timeout', async () => { + const { signal, isTimedOut, cleanup } = createTimeoutController(50); + + expect(signal.aborted).toBe(false); + + // Wait for timeout + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(signal.aborted).toBe(true); + expect(isTimedOut()).toBe(true); + + cleanup(); + }); + + it('should not abort if cleaned up before timeout', async () => { + const { signal, cleanup } = createTimeoutController(100); + + cleanup(); + + await new Promise(resolve => setTimeout(resolve, 150)); + + expect(signal.aborted).toBe(false); + }); + }); + + describe('Circuit Breaker', () => { + const circuitKey = 'test-circuit'; + + describe('isCircuitOpen', () => { + it('should return false for new circuit', () => { + expect(isCircuitOpen(circuitKey)).toBe(false); + }); + + it('should return false when failures are below threshold', () => { + recordFailure(circuitKey); + recordFailure(circuitKey); + recordFailure(circuitKey); + recordFailure(circuitKey); + // 4 failures, threshold is 5 + + expect(isCircuitOpen(circuitKey)).toBe(false); + }); + + it('should return true when failures reach threshold', () => { + for (let i = 0; i < 5; i++) { + recordFailure(circuitKey); + } + + expect(isCircuitOpen(circuitKey)).toBe(true); + }); + }); + + describe('recordSuccess', () => { + it('should reset failure count on success', () => { + recordFailure(circuitKey); + recordFailure(circuitKey); + recordSuccess(circuitKey); + + // After success, failures reset, so 5 more needed + for (let i = 0; i < 4; i++) { + recordFailure(circuitKey); + } + + expect(isCircuitOpen(circuitKey)).toBe(false); + }); + }); + + describe('recordFailure', () => { + it('should increment failure count', () => { + recordFailure(circuitKey); + + // Get state to verify (circuit still closed) + expect(isCircuitOpen(circuitKey)).toBe(false); + + // Add more failures to reach threshold + for (let i = 0; i < 4; i++) { + recordFailure(circuitKey); + } + + expect(isCircuitOpen(circuitKey)).toBe(true); + }); + }); + + describe('resetCircuit', () => { + it('should reset circuit state', () => { + for (let i = 0; i < 5; i++) { + recordFailure(circuitKey); + } + + expect(isCircuitOpen(circuitKey)).toBe(true); + + resetCircuit(circuitKey); + + expect(isCircuitOpen(circuitKey)).toBe(false); + }); + }); + + describe('Circuit transitions', () => { + it('should transition from open to half-open after reset timeout', async () => { + const fastConfig = { + failureThreshold: 2, + resetTimeout: 50, + successThreshold: 1, + }; + + recordFailure(circuitKey, fastConfig); + recordFailure(circuitKey, fastConfig); + + expect(isCircuitOpen(circuitKey, fastConfig)).toBe(true); + + // Wait for reset timeout + await new Promise(resolve => setTimeout(resolve, 100)); + + // Should transition to half-open (allows requests) + expect(isCircuitOpen(circuitKey, fastConfig)).toBe(false); + }); + + it('should close circuit after successes in half-open state', async () => { + const fastConfig = { + failureThreshold: 2, + resetTimeout: 50, + successThreshold: 2, + }; + + recordFailure(circuitKey, fastConfig); + recordFailure(circuitKey, fastConfig); + + // Wait for half-open + await new Promise(resolve => setTimeout(resolve, 100)); + isCircuitOpen(circuitKey, fastConfig); // Trigger transition + + recordSuccess(circuitKey, fastConfig); + recordSuccess(circuitKey, fastConfig); + + // Should be closed now, 2 failures needed again + recordFailure(circuitKey, fastConfig); + expect(isCircuitOpen(circuitKey, fastConfig)).toBe(false); + }); + + it('should reopen on failure in half-open state', async () => { + const fastConfig = { + failureThreshold: 2, + resetTimeout: 50, + successThreshold: 2, + }; + + recordFailure(circuitKey, fastConfig); + recordFailure(circuitKey, fastConfig); + + // Wait for half-open + await new Promise(resolve => setTimeout(resolve, 100)); + isCircuitOpen(circuitKey, fastConfig); // Trigger transition + + // Fail in half-open state + recordFailure(circuitKey, fastConfig); + + expect(isCircuitOpen(circuitKey, fastConfig)).toBe(true); + }); + }); + }); + + describe('Request Deduplication', () => { + describe('generateRequestKey', () => { + it('should generate unique keys for different requests', () => { + const key1 = generateRequestKey('GET', 'https://api.example.com/users'); + const key2 = generateRequestKey('GET', 'https://api.example.com/posts'); + const key3 = generateRequestKey('POST', 'https://api.example.com/users'); + + expect(key1).not.toBe(key2); + expect(key1).not.toBe(key3); + }); + + it('should generate same key for identical requests', () => { + const key1 = generateRequestKey('GET', 'https://api.example.com/users'); + const key2 = generateRequestKey('GET', 'https://api.example.com/users'); + + expect(key1).toBe(key2); + }); + + it('should include body hash in key', () => { + const key1 = generateRequestKey('POST', 'https://api.example.com/users', '{"name":"test"}'); + const key2 = generateRequestKey('POST', 'https://api.example.com/users', '{"name":"other"}'); + const key3 = generateRequestKey('POST', 'https://api.example.com/users', '{"name":"test"}'); + + expect(key1).not.toBe(key2); + expect(key1).toBe(key3); + }); + }); + + describe('deduplicateRequest', () => { + it('should return same promise for concurrent identical requests', async () => { + let callCount = 0; + const mockRequest = jest.fn().mockImplementation(async () => { + callCount++; + await new Promise(resolve => setTimeout(resolve, 50)); + return { data: 'result' }; + }); + + const key = 'test-dedupe'; + + // Start both requests concurrently + const promise1 = deduplicateRequest(key, mockRequest); + const promise2 = deduplicateRequest(key, mockRequest); + + const [result1, result2] = await Promise.all([promise1, promise2]); + + expect(callCount).toBe(1); + expect(result1).toEqual({ data: 'result' }); + expect(result2).toEqual({ data: 'result' }); + }); + + it('should make separate calls for sequential requests', async () => { + let callCount = 0; + const mockRequest = jest.fn().mockImplementation(async () => { + callCount++; + return { data: callCount }; + }); + + const key = 'test-sequential'; + + const result1 = await deduplicateRequest(key, mockRequest); + const result2 = await deduplicateRequest(key, mockRequest); + + expect(callCount).toBe(2); + expect(result1).toEqual({ data: 1 }); + expect(result2).toEqual({ data: 2 }); + }); + + it('should handle errors and allow retry', async () => { + let callCount = 0; + const mockRequest = jest.fn().mockImplementation(async () => { + callCount++; + if (callCount === 1) { + throw new Error('First call failed'); + } + return { data: 'success' }; + }); + + const key = 'test-error-retry'; + + // First call fails + await expect(deduplicateRequest(key, mockRequest)).rejects.toThrow('First call failed'); + + // Second call should succeed + const result = await deduplicateRequest(key, mockRequest); + expect(result).toEqual({ data: 'success' }); + expect(callCount).toBe(2); + }); + }); + }); + + describe('Error Type Detection', () => { + describe('isTimeoutError', () => { + it('should return true for timeout errors', () => { + const error1 = new Error('Request timed out'); + (error1 as any).code = ErrorCodes.NETWORK_TIMEOUT; + + const error2 = new Error('timeout'); + error2.name = 'TimeoutError'; + + const error3 = new Error('AbortError'); + error3.name = 'AbortError'; + + const error4 = new Error('Connection timeout occurred'); + + expect(isTimeoutError(error1)).toBe(true); + expect(isTimeoutError(error2)).toBe(true); + expect(isTimeoutError(error3)).toBe(true); + expect(isTimeoutError(error4)).toBe(true); + }); + + it('should return false for non-timeout errors', () => { + const error = new Error('Network error'); + (error as any).code = ErrorCodes.NETWORK_ERROR; + + expect(isTimeoutError(error)).toBe(false); + expect(isTimeoutError(new Error('Something else'))).toBe(false); + expect(isTimeoutError(null)).toBe(false); + expect(isTimeoutError(undefined)).toBe(false); + }); + }); + + describe('isNetworkError', () => { + it('should return true for network errors', () => { + const error1 = new Error('Network error'); + (error1 as any).code = ErrorCodes.NETWORK_ERROR; + + const error2 = new Error(''); + (error2 as any).code = ErrorCodes.NETWORK_OFFLINE; + + const error3 = new Error('Failed to fetch'); + + expect(isNetworkError(error1)).toBe(true); + expect(isNetworkError(error2)).toBe(true); + expect(isNetworkError(error3)).toBe(true); + }); + + it('should return true for timeout errors (which are network errors)', () => { + const error = new Error(''); + (error as any).code = ErrorCodes.NETWORK_TIMEOUT; + + expect(isNetworkError(error)).toBe(true); + }); + + it('should return false for non-network errors', () => { + const error = new Error('Validation failed'); + (error as any).code = ErrorCodes.VALIDATION_ERROR; + + expect(isNetworkError(error)).toBe(false); + expect(isNetworkError(new Error('Something else'))).toBe(false); + }); + }); + + describe('getErrorCode', () => { + it('should return error code if present', () => { + const error = new Error('Test'); + (error as any).code = 'CUSTOM_ERROR'; + + expect(getErrorCode(error)).toBe('CUSTOM_ERROR'); + }); + + it('should return NETWORK_TIMEOUT for timeout errors', () => { + const error = new Error(''); + error.name = 'TimeoutError'; + + expect(getErrorCode(error)).toBe(ErrorCodes.NETWORK_TIMEOUT); + }); + + it('should return NETWORK_OFFLINE for offline errors', () => { + const error = new Error('Device is offline'); + + expect(getErrorCode(error)).toBe(ErrorCodes.NETWORK_OFFLINE); + }); + + it('should return NETWORK_ERROR for general network errors', () => { + const error = new Error('Failed to fetch'); + + expect(getErrorCode(error)).toBe(ErrorCodes.NETWORK_ERROR); + }); + + it('should return UNKNOWN_ERROR for unknown errors', () => { + expect(getErrorCode(new Error('Random error'))).toBe(ErrorCodes.UNKNOWN_ERROR); + expect(getErrorCode(null)).toBe(ErrorCodes.UNKNOWN_ERROR); + expect(getErrorCode(undefined)).toBe(ErrorCodes.UNKNOWN_ERROR); + expect(getErrorCode(42)).toBe(ErrorCodes.UNKNOWN_ERROR); + }); + }); + }); + + describe('Error Conversion', () => { + describe('toApiError', () => { + it('should convert Error to ApiError', () => { + const error = new Error('Something failed'); + (error as any).code = 'TEST_ERROR'; + + const apiError = toApiError(error); + + expect(apiError.message).toBe('Something failed'); + expect(apiError.code).toBe('TEST_ERROR'); + }); + + it('should convert string to ApiError', () => { + const apiError = toApiError('Error message'); + + expect(apiError.message).toBe('Error message'); + expect(apiError.code).toBe(ErrorCodes.UNKNOWN_ERROR); + }); + + it('should handle unknown values', () => { + const apiError = toApiError(null); + + expect(apiError.message).toBe('An unknown error occurred'); + expect(apiError.code).toBe(ErrorCodes.UNKNOWN_ERROR); + }); + }); + + describe('createNetworkErrorResponse', () => { + it('should create ApiResponse with error', () => { + const response = createNetworkErrorResponse('TEST_CODE', 'Test message'); + + expect(response.ok).toBe(false); + expect(response.error).toEqual({ + message: 'Test message', + code: 'TEST_CODE', + }); + }); + }); + }); +}); diff --git a/utils/networkErrorRecovery.ts b/utils/networkErrorRecovery.ts new file mode 100644 index 0000000..45784e7 --- /dev/null +++ b/utils/networkErrorRecovery.ts @@ -0,0 +1,586 @@ +/** + * 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 { + 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(); + +/** + * 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>(); + +/** + * 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( + key: string, + request: () => Promise +): Promise { + // 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 { + 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 => { + 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( + fn: () => Promise, + maxRetries: number, + baseDelay: number, + backoffMultiplier: number +): Promise { + 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( + code: string, + message: string +): ApiResponse { + 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 };