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
|
* Error Components Export
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export { ErrorBoundary, withErrorBoundary } from './ErrorBoundary';
|
||||||
export { ErrorToast } from './ErrorToast';
|
export { ErrorToast } from './ErrorToast';
|
||||||
export { FieldError, FieldErrorSummary } from './FieldError';
|
export { FieldError, FieldErrorSummary } from './FieldError';
|
||||||
export { FullScreenError, EmptyState, OfflineState } from './FullScreenError';
|
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