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>
This commit is contained in:
Sergei 2026-02-01 09:29:19 -08:00
parent 6960f248e0
commit 3260119ece
7 changed files with 2384 additions and 0 deletions

View File

@ -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
* <ErrorBoundary
* fallback={<CustomErrorScreen />}
* onError={(error, info) => logToService(error, info)}
* >
* <App />
* </ErrorBoundary>
*/
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
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 (
<DefaultErrorFallback
error={this.state.error}
errorInfo={this.state.errorInfo}
onRetry={this.handleRetry}
showDetails={this.props.showDetails ?? __DEV__}
/>
);
}
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 (
<View style={styles.container}>
<View style={styles.content}>
{/* Icon */}
<View style={styles.iconContainer}>
<Ionicons name="bug" size={64} color="#EF4444" />
</View>
{/* Title */}
<Text style={styles.title}>Oops! Something went wrong</Text>
{/* Message */}
<Text style={styles.message}>
The app encountered an unexpected error. We apologize for the inconvenience.
</Text>
{/* Retry Button */}
{onRetry && (
<TouchableOpacity
style={styles.retryButton}
onPress={onRetry}
activeOpacity={0.8}
>
<Ionicons name="refresh" size={20} color="#fff" style={styles.buttonIcon} />
<Text style={styles.retryButtonText}>Try Again</Text>
</TouchableOpacity>
)}
{/* Error Details (Development Only) */}
{showDetails && error && (
<View style={styles.detailsContainer}>
<TouchableOpacity
style={styles.detailsHeader}
onPress={() => setDetailsExpanded(!detailsExpanded)}
activeOpacity={0.7}
>
<Text style={styles.detailsHeaderText}>
{detailsExpanded ? 'Hide' : 'Show'} Error Details
</Text>
<Ionicons
name={detailsExpanded ? 'chevron-up' : 'chevron-down'}
size={16}
color="#6B7280"
/>
</TouchableOpacity>
{detailsExpanded && (
<ScrollView style={styles.detailsContent}>
<Text style={styles.errorName}>{error.name}</Text>
<Text style={styles.errorMessage}>{error.message}</Text>
{errorInfo?.componentStack && (
<>
<Text style={styles.stackTitle}>Component Stack:</Text>
<Text style={styles.stackTrace}>
{errorInfo.componentStack}
</Text>
</>
)}
{error.stack && (
<>
<Text style={styles.stackTitle}>Error Stack:</Text>
<Text style={styles.stackTrace}>{error.stack}</Text>
</>
)}
</ScrollView>
)}
</View>
)}
</View>
</View>
);
}
// ==================== Wrapper HOC ====================
/**
* Higher-order component to wrap any component with ErrorBoundary
*
* @example
* const SafeScreen = withErrorBoundary(MyScreen, {
* fallback: <CustomError />,
* onError: logError,
* });
*/
export function withErrorBoundary<P extends object>(
WrappedComponent: React.ComponentType<P>,
errorBoundaryProps?: Omit<ErrorBoundaryProps, 'children'>
) {
const displayName = WrappedComponent.displayName || WrappedComponent.name || 'Component';
const ComponentWithErrorBoundary = (props: P) => (
<ErrorBoundary {...errorBoundaryProps}>
<WrappedComponent {...props} />
</ErrorBoundary>
);
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;

View File

@ -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 <Text>Content rendered</Text>;
}
// 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(
<ErrorBoundary>
<Text>Hello World</Text>
</ErrorBoundary>
);
expect(getByText('Hello World')).toBeTruthy();
});
it('should render fallback UI when error occurs', () => {
const { getByText } = render(
<ErrorBoundary>
<ThrowingComponent />
</ErrorBoundary>
);
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(
<ErrorBoundary fallback={<Text>Custom Error UI</Text>}>
<ThrowingComponent />
</ErrorBoundary>
);
expect(getByText('Custom Error UI')).toBeTruthy();
});
it('should call onError callback when error occurs', () => {
const onError = jest.fn();
render(
<ErrorBoundary onError={onError}>
<ThrowingComponent />
</ErrorBoundary>
);
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(
<ErrorBoundary onRetry={onRetry}>
<ThrowingComponent />
</ErrorBoundary>
);
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 <Text>Recovered</Text>;
};
const { getByText, queryByText, rerender } = render(
<ErrorBoundary onRetry={() => { shouldThrow = false; }}>
<TestComponent />
</ErrorBoundary>
);
// 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(
<ErrorBoundary>
<TestComponent />
</ErrorBoundary>
);
expect(getByText('Recovered')).toBeTruthy();
expect(queryByText('Oops! Something went wrong')).toBeNull();
});
it('should reset error state when resetKey changes', () => {
const { rerender, getByText, queryByText } = render(
<ErrorBoundary resetKey="key1">
<ThrowingComponent shouldThrow={true} />
</ErrorBoundary>
);
expect(getByText('Oops! Something went wrong')).toBeTruthy();
// Change resetKey and provide non-throwing child
rerender(
<ErrorBoundary resetKey="key2">
<ThrowingComponent shouldThrow={false} />
</ErrorBoundary>
);
expect(getByText('Content rendered')).toBeTruthy();
expect(queryByText('Oops! Something went wrong')).toBeNull();
});
it('should show error details in development mode', () => {
const { getByText, queryByText } = render(
<ErrorBoundary showDetails={true}>
<ThrowingComponent />
</ErrorBoundary>
);
// 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(
<ErrorBoundary showDetails={false}>
<ThrowingComponent />
</ErrorBoundary>
);
expect(queryByText(/Error Details/)).toBeNull();
});
});
describe('withErrorBoundary HOC', () => {
it('should wrap component with error boundary', () => {
const MyComponent = () => <Text>My Component</Text>;
const WrappedComponent = withErrorBoundary(MyComponent);
const { getByText } = render(<WrappedComponent />);
expect(getByText('My Component')).toBeTruthy();
});
it('should catch errors from wrapped component', () => {
const onError = jest.fn();
const WrappedComponent = withErrorBoundary(ThrowingComponent, { onError });
const { getByText } = render(<WrappedComponent />);
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) => <Text>{message}</Text>;
const WrappedComponent = withErrorBoundary(MyComponent);
const { getByText } = render(<WrappedComponent message="Hello Props" />);
expect(getByText('Hello Props')).toBeTruthy();
});
it('should set correct displayName', () => {
const MyComponent = () => <Text>Test</Text>;
MyComponent.displayName = 'MyComponent';
const WrappedComponent = withErrorBoundary(MyComponent);
expect(WrappedComponent.displayName).toBe('withErrorBoundary(MyComponent)');
});
});

View File

@ -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';

View File

@ -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<typeof mockData>);
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<never>);
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<never>);
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<any>) => void;
const mockApiCall = jest.fn().mockReturnValue(
new Promise(resolve => {
resolvePromise = resolve;
})
);
const { result } = renderHook(() => useApiCall(mockApiCall));
// Start execution
let executePromise: Promise<any>;
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);
});
});

View File

@ -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<string, unknown>;
/** 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<T> {
/** Execute the API call */
execute: () => Promise<ApiResponse<T>>;
/** 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<ApiResponse<T>>;
}
/**
* 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 <Loading />;
* if (error) return <ErrorMessage error={error} />;
* return <BeneficiaryView data={data} />;
* }
*/
export function useApiCall<T>(
apiCall: () => Promise<ApiResponse<T>>,
options: ApiCallOptions = {}
): UseApiCallResult<T> {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<AppError | null>(null);
const [data, setData] = useState<T | null>(null);
const showError = useShowErrorSafe();
const lastCallRef = useRef(apiCall);
// Update ref when apiCall changes
lastCallRef.current = apiCall;
const execute = useCallback(async (): Promise<ApiResponse<T>> => {
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<AppError[]>([]);
const showError = useShowErrorSafe();
const executeAll = useCallback(
async <T extends unknown[]>(
calls: Array<() => Promise<ApiResponse<any>>>,
options: ApiCallOptions = {}
): Promise<Array<ApiResponse<any>>> => {
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<TData, TVariables>(
mutationFn: (variables: TVariables) => Promise<ApiResponse<TData>>,
options: ApiCallOptions & {
/** Called before mutation starts */
onMutate?: (variables: TVariables) => void | Promise<void>;
/** Called when mutation succeeds */
onSuccess?: (data: TData, variables: TVariables) => void | Promise<void>;
/** Called when mutation fails */
onError?: (error: AppError, variables: TVariables) => void | Promise<void>;
/** Called after mutation completes (success or error) */
onSettled?: (data: TData | undefined, error: AppError | null, variables: TVariables) => void | Promise<void>;
} = {}
) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<AppError | null>(null);
const [data, setData] = useState<TData | null>(null);
const showError = useShowErrorSafe();
const mutate = useCallback(
async (variables: TVariables): Promise<ApiResponse<TData>> => {
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;

View File

@ -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',
});
});
});
});
});

View File

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