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:
parent
6960f248e0
commit
3260119ece
350
components/errors/ErrorBoundary.tsx
Normal file
350
components/errors/ErrorBoundary.tsx
Normal 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;
|
||||
218
components/errors/__tests__/ErrorBoundary.test.tsx
Normal file
218
components/errors/__tests__/ErrorBoundary.test.tsx
Normal 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)');
|
||||
});
|
||||
});
|
||||
@ -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';
|
||||
|
||||
406
hooks/__tests__/useApiWithErrorHandling.test.ts
Normal file
406
hooks/__tests__/useApiWithErrorHandling.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
371
hooks/useApiWithErrorHandling.ts
Normal file
371
hooks/useApiWithErrorHandling.ts
Normal 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;
|
||||
452
utils/__tests__/networkErrorRecovery.test.ts
Normal file
452
utils/__tests__/networkErrorRecovery.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
586
utils/networkErrorRecovery.ts
Normal file
586
utils/networkErrorRecovery.ts
Normal 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 };
|
||||
Loading…
x
Reference in New Issue
Block a user