Files
orion/docs/development/frontend-exception-handling.md

16 KiB

Frontend Exception Handling Guide

Understanding Your API's Exception Structure

Your API returns consistent error responses with this structure:

{
  "error_code": "PRODUCT_NOT_FOUND",
  "message": "MarketplaceProduct with ID 'ABC123' not found",
  "status_code": 404,
  "field": "marketplace_product_id",
  "details": {
    "resource_type": "MarketplaceProduct",
    "identifier": "ABC123"
  }
}

Frontend Error Handling Strategy

1. HTTP Client Configuration

Configure your HTTP client to handle error responses consistently:

Axios Example

// 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

// 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:

// 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.',
  
  // MarketplaceProduct errors
  PRODUCT_NOT_FOUND: 'MarketplaceProduct 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.',
  
  // Inventory errors
  INSUFFICIENT_INVENTORY: 'Not enough inventory available for this operation.',
  INVENTORY_NOT_FOUND: 'No inventory information found for this product.',
  NEGATIVE_INVENTORY_NOT_ALLOWED: 'Inventory 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

// 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

// 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('MarketplaceProduct 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>MarketplaceProduct 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>MarketplaceProduct 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 MarketplaceProduct'}
      </button>
    </form>
  );
};

4. Global Error Handler

React Error Boundary

// 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

// 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

// 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;
  }
);

Inventory Management Errors

// components/InventoryManager.jsx
const handleInventoryUpdate = async (gtin, location, quantity) => {
  try {
    await updateInventory(gtin, location, quantity);
    notificationManager.notify('success', 'Inventory updated successfully');
  } catch (error) {
    switch (error.errorCode) {
      case 'INSUFFICIENT_INVENTORY':
        const { available_quantity, requested_quantity } = error.details;
        notificationManager.notify('error', 
          `Cannot remove ${requested_quantity} items. Only ${available_quantity} available.`
        );
        break;
      case 'INVENTORY_NOT_FOUND':
        notificationManager.notify('error', 'No inventory record found for this product');
        break;
      case 'NEGATIVE_INVENTORY_NOT_ALLOWED':
        notificationManager.notify('error', 'Inventory quantity cannot be negative');
        break;
      default:
        notificationManager.notifyError(error);
    }
  }
};

Import Job Monitoring

// 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

// 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.