Exception handling enhancement
This commit is contained in:
577
docs/development/frontend-exception-handling.md
Normal file
577
docs/development/frontend-exception-handling.md
Normal file
@@ -0,0 +1,577 @@
|
||||
# Frontend Exception Handling Guide
|
||||
|
||||
## Understanding Your API's Exception Structure
|
||||
|
||||
Your API returns consistent error responses with this structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"error_code": "PRODUCT_NOT_FOUND",
|
||||
"message": "Product with ID 'ABC123' not found",
|
||||
"status_code": 404,
|
||||
"field": "product_id",
|
||||
"details": {
|
||||
"resource_type": "Product",
|
||||
"identifier": "ABC123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Frontend Error Handling Strategy
|
||||
|
||||
### 1. HTTP Client Configuration
|
||||
|
||||
Configure your HTTP client to handle error responses consistently:
|
||||
|
||||
#### Axios Example
|
||||
```javascript
|
||||
// api/client.js
|
||||
import axios from 'axios';
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: 'https://your-api.com/api/v1',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Response interceptor for error handling
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.data) {
|
||||
// Transform API error to standardized format
|
||||
const apiError = {
|
||||
errorCode: error.response.data.error_code,
|
||||
message: error.response.data.message,
|
||||
statusCode: error.response.data.status_code,
|
||||
field: error.response.data.field,
|
||||
details: error.response.data.details,
|
||||
originalError: error
|
||||
};
|
||||
throw apiError;
|
||||
}
|
||||
|
||||
// Handle network errors
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
throw {
|
||||
errorCode: 'NETWORK_TIMEOUT',
|
||||
message: 'Request timed out. Please try again.',
|
||||
statusCode: 408
|
||||
};
|
||||
}
|
||||
|
||||
throw {
|
||||
errorCode: 'NETWORK_ERROR',
|
||||
message: 'Network error. Please check your connection.',
|
||||
statusCode: 0
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
```
|
||||
|
||||
#### Fetch Example
|
||||
```javascript
|
||||
// api/client.js
|
||||
class ApiClient {
|
||||
constructor(baseURL) {
|
||||
this.baseURL = baseURL;
|
||||
}
|
||||
|
||||
async request(endpoint, options = {}) {
|
||||
try {
|
||||
const response = await fetch(`${this.baseURL}${endpoint}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw {
|
||||
errorCode: data.error_code,
|
||||
message: data.message,
|
||||
statusCode: data.status_code,
|
||||
field: data.field,
|
||||
details: data.details,
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error.errorCode) {
|
||||
throw error; // Re-throw API errors
|
||||
}
|
||||
|
||||
// Handle network errors
|
||||
throw {
|
||||
errorCode: 'NETWORK_ERROR',
|
||||
message: 'Failed to connect to server',
|
||||
statusCode: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient('https://your-api.com/api/v1');
|
||||
```
|
||||
|
||||
### 2. Error Code Mapping
|
||||
|
||||
Create mappings for user-friendly messages:
|
||||
|
||||
```javascript
|
||||
// constants/errorMessages.js
|
||||
export const ERROR_MESSAGES = {
|
||||
// Authentication errors
|
||||
INVALID_CREDENTIALS: 'Invalid username or password. Please try again.',
|
||||
TOKEN_EXPIRED: 'Your session has expired. Please log in again.',
|
||||
USER_NOT_ACTIVE: 'Your account has been deactivated. Contact support.',
|
||||
|
||||
// Product errors
|
||||
PRODUCT_NOT_FOUND: 'Product not found. It may have been removed.',
|
||||
PRODUCT_ALREADY_EXISTS: 'A product with this ID already exists.',
|
||||
INVALID_PRODUCT_DATA: 'Please check the product information and try again.',
|
||||
|
||||
// Stock errors
|
||||
INSUFFICIENT_STOCK: 'Not enough stock available for this operation.',
|
||||
STOCK_NOT_FOUND: 'No stock information found for this product.',
|
||||
NEGATIVE_STOCK_NOT_ALLOWED: 'Stock quantity cannot be negative.',
|
||||
|
||||
// Shop errors
|
||||
SHOP_NOT_FOUND: 'Shop not found or no longer available.',
|
||||
UNAUTHORIZED_SHOP_ACCESS: 'You do not have permission to access this shop.',
|
||||
SHOP_ALREADY_EXISTS: 'A shop with this code already exists.',
|
||||
MAX_SHOPS_REACHED: 'You have reached the maximum number of shops allowed.',
|
||||
|
||||
// Import errors
|
||||
IMPORT_JOB_NOT_FOUND: 'Import job not found.',
|
||||
IMPORT_JOB_CANNOT_BE_CANCELLED: 'This import job cannot be cancelled at this time.',
|
||||
MARKETPLACE_CONNECTION_FAILED: 'Failed to connect to marketplace. Please try again.',
|
||||
|
||||
// Generic fallbacks
|
||||
VALIDATION_ERROR: 'Please check your input and try again.',
|
||||
NETWORK_ERROR: 'Connection error. Please check your internet connection.',
|
||||
NETWORK_TIMEOUT: 'Request timed out. Please try again.',
|
||||
INTERNAL_SERVER_ERROR: 'Something went wrong. Please try again later.',
|
||||
};
|
||||
|
||||
export const getErrorMessage = (errorCode, fallbackMessage = null) => {
|
||||
return ERROR_MESSAGES[errorCode] || fallbackMessage || 'An unexpected error occurred.';
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Component Error Handling
|
||||
|
||||
#### React Hook for Error Handling
|
||||
```javascript
|
||||
// hooks/useApiError.js
|
||||
import { useState } from 'react';
|
||||
import { getErrorMessage } from '../constants/errorMessages';
|
||||
|
||||
export const useApiError = () => {
|
||||
const [error, setError] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleApiCall = async (apiCall) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await apiCall();
|
||||
setIsLoading(false);
|
||||
return result;
|
||||
} catch (apiError) {
|
||||
setIsLoading(false);
|
||||
setError({
|
||||
code: apiError.errorCode,
|
||||
message: getErrorMessage(apiError.errorCode, apiError.message),
|
||||
field: apiError.field,
|
||||
details: apiError.details
|
||||
});
|
||||
throw apiError; // Re-throw for component-specific handling
|
||||
}
|
||||
};
|
||||
|
||||
const clearError = () => setError(null);
|
||||
|
||||
return { error, isLoading, handleApiCall, clearError };
|
||||
};
|
||||
```
|
||||
|
||||
#### Form Error Handling
|
||||
```javascript
|
||||
// components/ProductForm.jsx
|
||||
import React, { useState } from 'react';
|
||||
import { useApiError } from '../hooks/useApiError';
|
||||
import { createProduct } from '../api/products';
|
||||
|
||||
const ProductForm = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
product_id: '',
|
||||
name: '',
|
||||
price: ''
|
||||
});
|
||||
const [fieldErrors, setFieldErrors] = useState({});
|
||||
const { error, isLoading, handleApiCall, clearError } = useApiError();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setFieldErrors({});
|
||||
clearError();
|
||||
|
||||
try {
|
||||
await handleApiCall(() => createProduct(formData));
|
||||
// Success handling
|
||||
alert('Product created successfully!');
|
||||
setFormData({ product_id: '', name: '', price: '' });
|
||||
} catch (apiError) {
|
||||
// Handle field-specific errors
|
||||
if (apiError.field) {
|
||||
setFieldErrors({ [apiError.field]: apiError.message });
|
||||
}
|
||||
|
||||
// Handle specific error codes
|
||||
switch (apiError.errorCode) {
|
||||
case 'PRODUCT_ALREADY_EXISTS':
|
||||
setFieldErrors({ product_id: 'This product ID is already taken' });
|
||||
break;
|
||||
case 'INVALID_PRODUCT_DATA':
|
||||
if (apiError.field) {
|
||||
setFieldErrors({ [apiError.field]: apiError.message });
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// Generic error is handled by useApiError hook
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{error && !error.field && (
|
||||
<div className="error-banner">
|
||||
{error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-field">
|
||||
<label>Product ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.product_id}
|
||||
onChange={(e) => setFormData({...formData, product_id: e.target.value})}
|
||||
className={fieldErrors.product_id ? 'error' : ''}
|
||||
/>
|
||||
{fieldErrors.product_id && (
|
||||
<span className="field-error">{fieldErrors.product_id}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label>Product Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({...formData, name: e.target.value})}
|
||||
className={fieldErrors.name ? 'error' : ''}
|
||||
/>
|
||||
{fieldErrors.name && (
|
||||
<span className="field-error">{fieldErrors.name}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={isLoading}>
|
||||
{isLoading ? 'Creating...' : 'Create Product'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 4. Global Error Handler
|
||||
|
||||
#### React Error Boundary
|
||||
```javascript
|
||||
// components/ErrorBoundary.jsx
|
||||
import React from 'react';
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error('Error caught by boundary:', error, errorInfo);
|
||||
|
||||
// Log to error reporting service
|
||||
if (window.errorReporting) {
|
||||
window.errorReporting.captureException(error, {
|
||||
extra: errorInfo
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="error-fallback">
|
||||
<h2>Something went wrong</h2>
|
||||
<p>We're sorry, but something unexpected happened.</p>
|
||||
<button onClick={() => window.location.reload()}>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Global Toast Notifications
|
||||
```javascript
|
||||
// utils/notifications.js
|
||||
class NotificationManager {
|
||||
constructor() {
|
||||
this.notifications = [];
|
||||
this.listeners = [];
|
||||
}
|
||||
|
||||
subscribe(callback) {
|
||||
this.listeners.push(callback);
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter(l => l !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
notify(type, message, options = {}) {
|
||||
const notification = {
|
||||
id: Date.now(),
|
||||
type, // 'success', 'error', 'warning', 'info'
|
||||
message,
|
||||
duration: options.duration || 5000,
|
||||
...options
|
||||
};
|
||||
|
||||
this.notifications.push(notification);
|
||||
this.listeners.forEach(callback => callback(this.notifications));
|
||||
|
||||
if (notification.duration > 0) {
|
||||
setTimeout(() => this.remove(notification.id), notification.duration);
|
||||
}
|
||||
}
|
||||
|
||||
notifyError(apiError) {
|
||||
const message = getErrorMessage(apiError.errorCode, apiError.message);
|
||||
this.notify('error', message, {
|
||||
errorCode: apiError.errorCode,
|
||||
details: apiError.details
|
||||
});
|
||||
}
|
||||
|
||||
remove(id) {
|
||||
this.notifications = this.notifications.filter(n => n.id !== id);
|
||||
this.listeners.forEach(callback => callback(this.notifications));
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationManager = new NotificationManager();
|
||||
```
|
||||
|
||||
### 5. Specific Error Handling Patterns
|
||||
|
||||
#### Authentication Errors
|
||||
```javascript
|
||||
// api/auth.js
|
||||
import { apiClient } from './client';
|
||||
import { notificationManager } from '../utils/notifications';
|
||||
|
||||
export const login = async (credentials) => {
|
||||
try {
|
||||
const response = await apiClient.post('/auth/login', credentials);
|
||||
localStorage.setItem('token', response.access_token);
|
||||
return response;
|
||||
} catch (error) {
|
||||
switch (error.errorCode) {
|
||||
case 'INVALID_CREDENTIALS':
|
||||
notificationManager.notify('error', 'Invalid username or password');
|
||||
break;
|
||||
case 'USER_NOT_ACTIVE':
|
||||
notificationManager.notify('error', 'Account deactivated. Contact support.');
|
||||
break;
|
||||
default:
|
||||
notificationManager.notifyError(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle token expiration globally
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.data?.error_code === 'TOKEN_EXPIRED') {
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
notificationManager.notify('warning', 'Session expired. Please log in again.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
#### Stock Management Errors
|
||||
```javascript
|
||||
// components/StockManager.jsx
|
||||
const handleStockUpdate = async (gtin, location, quantity) => {
|
||||
try {
|
||||
await updateStock(gtin, location, quantity);
|
||||
notificationManager.notify('success', 'Stock updated successfully');
|
||||
} catch (error) {
|
||||
switch (error.errorCode) {
|
||||
case 'INSUFFICIENT_STOCK':
|
||||
const { available_quantity, requested_quantity } = error.details;
|
||||
notificationManager.notify('error',
|
||||
`Cannot remove ${requested_quantity} items. Only ${available_quantity} available.`
|
||||
);
|
||||
break;
|
||||
case 'STOCK_NOT_FOUND':
|
||||
notificationManager.notify('error', 'No stock record found for this product');
|
||||
break;
|
||||
case 'NEGATIVE_STOCK_NOT_ALLOWED':
|
||||
notificationManager.notify('error', 'Stock quantity cannot be negative');
|
||||
break;
|
||||
default:
|
||||
notificationManager.notifyError(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### Import Job Monitoring
|
||||
```javascript
|
||||
// components/ImportJobStatus.jsx
|
||||
const ImportJobStatus = ({ jobId }) => {
|
||||
const [job, setJob] = useState(null);
|
||||
const { error, isLoading, handleApiCall } = useApiError();
|
||||
|
||||
const fetchJobStatus = useCallback(async () => {
|
||||
try {
|
||||
const jobData = await handleApiCall(() => getImportJobStatus(jobId));
|
||||
setJob(jobData);
|
||||
} catch (error) {
|
||||
switch (error.errorCode) {
|
||||
case 'IMPORT_JOB_NOT_FOUND':
|
||||
notificationManager.notify('error', 'Import job not found or has been deleted');
|
||||
break;
|
||||
case 'IMPORT_JOB_NOT_OWNED':
|
||||
notificationManager.notify('error', 'You do not have access to this import job');
|
||||
break;
|
||||
default:
|
||||
notificationManager.notifyError(error);
|
||||
}
|
||||
}
|
||||
}, [jobId, handleApiCall]);
|
||||
|
||||
const cancelJob = async () => {
|
||||
try {
|
||||
await handleApiCall(() => cancelImportJob(jobId));
|
||||
notificationManager.notify('success', 'Import job cancelled successfully');
|
||||
fetchJobStatus(); // Refresh status
|
||||
} catch (error) {
|
||||
switch (error.errorCode) {
|
||||
case 'IMPORT_JOB_CANNOT_BE_CANCELLED':
|
||||
const { current_status } = error.details;
|
||||
notificationManager.notify('error',
|
||||
`Cannot cancel job in ${current_status} status`
|
||||
);
|
||||
break;
|
||||
default:
|
||||
notificationManager.notifyError(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="import-job-status">
|
||||
{/* Job status UI */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 6. Error Logging and Monitoring
|
||||
|
||||
```javascript
|
||||
// utils/errorReporting.js
|
||||
export const logError = (error, context = {}) => {
|
||||
const errorData = {
|
||||
timestamp: new Date().toISOString(),
|
||||
errorCode: error.errorCode,
|
||||
message: error.message,
|
||||
statusCode: error.statusCode,
|
||||
field: error.field,
|
||||
details: error.details,
|
||||
userAgent: navigator.userAgent,
|
||||
url: window.location.href,
|
||||
userId: getCurrentUserId(),
|
||||
...context
|
||||
};
|
||||
|
||||
// Log to console in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('API Error:', errorData);
|
||||
}
|
||||
|
||||
// Send to error tracking service
|
||||
if (window.errorTracker) {
|
||||
window.errorTracker.captureException(error, {
|
||||
tags: {
|
||||
errorCode: error.errorCode,
|
||||
statusCode: error.statusCode
|
||||
},
|
||||
extra: errorData
|
||||
});
|
||||
}
|
||||
|
||||
// Store in local storage for offline analysis
|
||||
try {
|
||||
const existingErrors = JSON.parse(localStorage.getItem('apiErrors') || '[]');
|
||||
existingErrors.push(errorData);
|
||||
|
||||
// Keep only last 50 errors
|
||||
if (existingErrors.length > 50) {
|
||||
existingErrors.splice(0, existingErrors.length - 50);
|
||||
}
|
||||
|
||||
localStorage.setItem('apiErrors', JSON.stringify(existingErrors));
|
||||
} catch (e) {
|
||||
console.warn('Failed to store error locally:', e);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 7. Best Practices Summary
|
||||
|
||||
1. **Consistent Error Structure**: Always expect the same error format from your API
|
||||
2. **User-Friendly Messages**: Map error codes to readable messages
|
||||
3. **Field-Specific Errors**: Handle validation errors at the field level
|
||||
4. **Progressive Enhancement**: Start with basic error handling, add sophistication gradually
|
||||
5. **Logging**: Track errors for debugging and improving user experience
|
||||
6. **Fallback Handling**: Always have fallback messages for unknown error codes
|
||||
7. **Loading States**: Show appropriate loading indicators during API calls
|
||||
8. **Retry Logic**: Implement retry for transient network errors
|
||||
9. **Offline Handling**: Consider offline scenarios and network recovery
|
||||
|
||||
This approach gives you robust error handling that scales with your application while providing excellent user experience and debugging capabilities.
|
||||
Reference in New Issue
Block a user