Skip to content

πŸ›‘οΈ Task 10.3: Error Handling Hardening - Bulletproof DemoΒ #86

@Raafay-Qureshi

Description

@Raafay-Qureshi

πŸ›‘οΈ Task 10.3: Error Handling Hardening - Bulletproof Demo

Implement comprehensive error handling and graceful degradation for a bulletproof demo.

πŸ“ Description

Add defensive error handling throughout the application to ensure nothing crashes during the demo. Implement error boundaries, API error handling, WebSocket reconnection logic, fallback states, user-friendly error messages, and automatic recovery mechanisms. The goal is that even if something goes wrong, the demo continues gracefully.

🎯 Acceptance Criteria

  • React Error Boundaries implemented
  • API errors caught and displayed
  • WebSocket disconnection handled gracefully
  • Network timeout handling
  • Invalid data handling
  • User-friendly error messages
  • Automatic retry logic
  • Error logging (console)
  • No app crashes under any circumstance
  • Graceful degradation tested

πŸ› οΈ Implementation

Error Boundary Component

Create src/components/ErrorBoundary.js:

import React from 'react';
import './ErrorBoundary.css';

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      hasError: false,
      error: null,
      errorInfo: null,
    };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    console.error('Error Boundary caught error:', error, errorInfo);
    this.setState({
      error,
      errorInfo,
    });
    
    // Log to error reporting service (if you have one)
    // logErrorToService(error, errorInfo);
  }

  handleReset = () => {
    this.setState({
      hasError: false,
      error: null,
      errorInfo: null,
    });
    window.location.reload();
  };

  render() {
    if (this.state.hasError) {
      return (
        <div className="error-boundary">
          <div className="error-content">
            <div className="error-icon">⚠️</div>
            <h1>Something Went Wrong</h1>
            <p className="error-message">
              The application encountered an unexpected error. 
              This shouldn't happen during the demo!
            </p>
            
            {process.env.NODE_ENV === 'development' && this.state.error && (
              <details className="error-details">
                <summary>Error Details (Development Only)</summary>
                <pre className="error-stack">
                  {this.state.error.toString()}
                  {this.state.errorInfo?.componentStack}
                </pre>
              </details>
            )}
            
            <div className="error-actions">
              <button onClick={this.handleReset} className="primary-button">
                Reload Application
              </button>
              <button 
                onClick={() => window.location.href = '/'} 
                className="secondary-button"
              >
                Go to Home
              </button>
            </div>
            
            <p className="error-help">
              If this persists, please contact the development team.
            </p>
          </div>
        </div>
      );
    }

    return this.props.children;
  }
}

export default ErrorBoundary;

Create src/components/ErrorBoundary.css:

.error-boundary {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 100vh;
  padding: 2rem;
  background: var(--dark-bg);
}

.error-content {
  max-width: 600px;
  text-align: center;
}

.error-icon {
  font-size: 4rem;
  margin-bottom: 1rem;
  animation: shake 0.5s ease-in-out;
}

@keyframes shake {
  0%, 100% { transform: translateX(0); }
  25% { transform: translateX(-10px); }
  75% { transform: translateX(10px); }
}

.error-content h1 {
  margin: 1rem 0;
  color: var(--danger-red);
}

.error-message {
  margin: 1rem 0 2rem;
  color: var(--text-secondary);
  line-height: 1.6;
}

.error-details {
  margin: 2rem 0;
  text-align: left;
  background: rgba(0, 0, 0, 0.3);
  border-radius: 8px;
  padding: 1rem;
}

.error-details summary {
  cursor: pointer;
  font-weight: 600;
  color: var(--text-primary);
}

.error-stack {
  margin-top: 1rem;
  padding: 1rem;
  background: #000;
  border-radius: 4px;
  color: #ff6b6b;
  font-size: 0.75rem;
  overflow-x: auto;
  white-space: pre-wrap;
  word-wrap: break-word;
}

.error-actions {
  display: flex;
  gap: 1rem;
  justify-content: center;
  margin: 2rem 0;
}

.primary-button, .secondary-button {
  padding: 0.75rem 1.5rem;
  border-radius: 6px;
  font-weight: 600;
  cursor: pointer;
  transition: all 0.2s ease;
}

.primary-button {
  background: var(--info-blue);
  color: white;
  border: none;
}

.primary-button:hover {
  background: #1976D2;
  transform: translateY(-2px);
}

.secondary-button {
  background: transparent;
  color: var(--text-primary);
  border: 1px solid var(--border);
}

.secondary-button:hover {
  background: rgba(255, 255, 255, 0.05);
}

.error-help {
  margin-top: 2rem;
  font-size: 0.875rem;
  color: var(--text-muted);
}

Wrap App with Error Boundary in src/index.js:

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import ErrorBoundary from './components/ErrorBoundary';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <ErrorBoundary>
      <App />
    </ErrorBoundary>
  </React.StrictMode>
);

API Error Handling

Update src/services/api.js:

import axios from 'axios';

const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';

// Create axios instance with defaults
const api = axios.create({
  baseURL: API_BASE_URL,
  timeout: 30000, // 30 second timeout
  headers: {
    'Content-Type': 'application/json',
  },
});

// Request interceptor
api.interceptors.request.use(
  config => {
    console.log(`[API] ${config.method.toUpperCase()} ${config.url}`);
    return config;
  },
  error => {
    console.error('[API] Request error:', error);
    return Promise.reject(error);
  }
);

// Response interceptor
api.interceptors.response.use(
  response => {
    console.log(`[API] Response from ${response.config.url}:`, response.status);
    return response;
  },
  error => {
    // Handle different error types
    if (error.response) {
      // Server responded with error status
      console.error(`[API] Server error ${error.response.status}:`, error.response.data);
      
      const errorMessage = error.response.data?.error || 
                          error.response.data?.message || 
                          `Server error: ${error.response.status}`;
      
      throw new Error(errorMessage);
      
    } else if (error.request) {
      // Request made but no response
      console.error('[API] No response from server:', error.request);
      throw new Error('Unable to connect to server. Please check your connection.');
      
    } else {
      // Error setting up request
      console.error('[API] Request setup error:', error.message);
      throw new Error(`Request error: ${error.message}`);
    }
  }
);

// Retry logic for failed requests
async function apiWithRetry(fn, maxRetries = 3, delay = 1000) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fn();
    } catch (error) {
      const isLastAttempt = i === maxRetries - 1;
      
      if (isLastAttempt) {
        throw error;
      }
      
      console.log(`[API] Retry attempt ${i + 1}/${maxRetries} after ${delay}ms`);
      await new Promise(resolve => setTimeout(resolve, delay));
      delay *= 2; // Exponential backoff
    }
  }
}

export const disasterAPI = {
  trigger: async (disasterData) => {
    return apiWithRetry(async () => {
      const response = await api.post('/disaster/trigger', disasterData);
      return response.data;
    });
  },

  getDisaster: async (disasterId) => {
    return apiWithRetry(async () => {
      const response = await api.get(`/disaster/${disasterId}`);
      return response.data;
    });
  },

  getPlan: async (disasterId) => {
    return apiWithRetry(async () => {
      const response = await api.get(`/disaster/${disasterId}/plan`);
      return response.data;
    });
  },
  
  healthCheck: async () => {
    try {
      const response = await api.get('/health', { timeout: 5000 });
      return response.data;
    } catch (error) {
      return { status: 'error', message: error.message };
    }
  },
};

export default api;

WebSocket Error Handling

Update src/services/websocket.js:

import React, { createContext, useContext, useEffect, useState, useRef } from 'react';
import io from 'socket.io-client';

const WebSocketContext = createContext(null);

export function useWebSocketContext() {
  const context = useContext(WebSocketContext);
  if (!context) {
    throw new Error('useWebSocketContext must be used within WebSocketProvider');
  }
  return context;
}

export function WebSocketProvider({ children, url }) {
  const [socket, setSocket] = useState(null);
  const [connected, setConnected] = useState(false);
  const [lastUpdate, setLastUpdate] = useState(null);
  const [error, setError] = useState(null);
  const reconnectAttempts = useRef(0);
  const reconnectTimer = useRef(null);
  const maxReconnectAttempts = 10;
  const reconnectDelay = 3000;

  useEffect(() => {
    console.log('[WebSocket] Initializing connection...');
    
    const socketUrl = url || process.env.REACT_APP_API_URL?.replace('/api', '') || 'http://localhost:5000';
    
    function connect() {
      const newSocket = io(socketUrl, {
        transports: ['websocket', 'polling'],
        reconnection: true,
        reconnectionAttempts: maxReconnectAttempts,
        reconnectionDelay: 1000,
        reconnectionDelayMax: 5000,
        timeout: 10000,
      });

      // Connection events
      newSocket.on('connect', () => {
        console.log('[WebSocket] Connected successfully');
        setConnected(true);
        setError(null);
        reconnectAttempts.current = 0;
      });

      newSocket.on('disconnect', (reason) => {
        console.log('[WebSocket] Disconnected:', reason);
        setConnected(false);
        
        if (reason === 'io server disconnect') {
          // Server initiated disconnect, try manual reconnect
          handleReconnect(newSocket);
        }
      });

      newSocket.on('connect_error', (err) => {
        console.error('[WebSocket] Connection error:', err.message);
        setError(err.message);
        reconnectAttempts.current += 1;
        
        if (reconnectAttempts.current >= maxReconnectAttempts) {
          console.error('[WebSocket] Max reconnection attempts reached');
          setError('Unable to establish connection. Using fallback mode.');
        } else {
          handleReconnect(newSocket);
        }
      });

      newSocket.on('reconnect', (attemptNumber) => {
        console.log(`[WebSocket] Reconnected after ${attemptNumber} attempts`);
        setConnected(true);
        setError(null);
      });

      newSocket.on('reconnect_failed', () => {
        console.error('[WebSocket] Reconnection failed');
        setError('Connection failed. System will operate in offline mode.');
      });

      newSocket.on('error', (err) => {
        console.error('[WebSocket] Socket error:', err);
        setError('WebSocket error occurred');
      });

      setSocket(newSocket);
    }

    function handleReconnect(socket) {
      if (reconnectTimer.current) {
        clearTimeout(reconnectTimer.current);
      }
      
      reconnectTimer.current = setTimeout(() => {
        console.log('[WebSocket] Attempting manual reconnect...');
        socket.connect();
      }, reconnectDelay);
    }

    connect();

    return () => {
      console.log('[WebSocket] Cleaning up connection');
      if (reconnectTimer.current) {
        clearTimeout(reconnectTimer.current);
      }
      if (socket) {
        socket.disconnect();
        socket.removeAllListeners();
      }
    };
  }, [url]);

  const contextValue = {
    socket,
    connected,
    lastUpdate,
    error,
    isReady: socket && connected && !error,
  };

  return (
    <WebSocketContext.Provider value={contextValue}>
      {children}
    </WebSocketContext.Provider>
  );
}

export default WebSocketContext;

Component-Level Error Handling

Update src/hooks/useDisaster.js with error recovery:

import { useState, useCallback, useEffect } from 'react';
import { disasterAPI } from '../services/api';
import useWebSocket from './useWebSocket';

function useDisaster() {
  const [disaster, setDisaster] = useState(null);
  const [plan, setPlan] = useState(null);
  const [loading, setLoading] = useState(false);
  const [progress, setProgress] = useState(0);
  const [error, setError] = useState(null);
  const [statusMessage, setStatusMessage] = useState('');
  const [retryCount, setRetryCount] = useState(0);

  const { connected, on, error: wsError } = useWebSocket();

  const triggerDisaster = useCallback(async (disasterData) => {
    try {
      console.log('[useDisaster] Triggering disaster:', disasterData);
      
      setLoading(true);
      setProgress(0);
      setError(null);
      setPlan(null);
      setRetryCount(0);
      setStatusMessage('Initializing disaster simulation...');

      const response = await disasterAPI.trigger(disasterData);
      
      console.log('[useDisaster] Disaster triggered successfully:', response);
      setDisaster(response);

      if (response.disaster_id && connected) {
        // Subscribe to WebSocket updates
        subscribeToDisaster(response.disaster_id);
        setStatusMessage('Subscribed to real-time updates');
      } else if (!connected) {
        setStatusMessage('WebSocket unavailable - using fallback mode');
        // Could implement polling fallback here
      }

    } catch (err) {
      console.error('[useDisaster] Failed to trigger disaster:', err);
      
      const errorMessage = err.message || 'Failed to trigger simulation';
      setError(errorMessage);
      setLoading(false);
      setStatusMessage('Error occurred');
      
      // Offer retry
      if (retryCount < 2) {
        setStatusMessage(`Error: ${errorMessage}. Retry available.`);
      }
    }
  }, [connected, retryCount]);

  const retryTrigger = useCallback(() => {
    setRetryCount(prev => prev + 1);
    // Would need to store last disasterData to retry
  }, []);

  const clearDisaster = useCallback(() => {
    console.log('[useDisaster] Clearing disaster state');
    setDisaster(null);
    setPlan(null);
    setLoading(false);
    setProgress(0);
    setError(null);
    setStatusMessage('');
    setRetryCount(0);
  }, []);

  // Handle WebSocket errors
  useEffect(() => {
    if (wsError && loading) {
      console.warn('[useDisaster] WebSocket error during processing:', wsError);
      setStatusMessage('Connection issue - switching to fallback mode...');
      // Could implement fallback to polling here
    }
  }, [wsError, loading]);

  // Timeout protection
  useEffect(() => {
    if (loading) {
      const timeout = setTimeout(() => {
        if (progress < 100) {
          console.error('[useDisaster] Processing timeout');
          setError('Processing timeout - system may be overloaded');
          setLoading(false);
        }
      }, 120000); // 2 minute timeout

      return () => clearTimeout(timeout);
    }
  }, [loading, progress]);

  return {
    disaster,
    plan,
    loading,
    progress,
    error,
    statusMessage,
    triggerDisaster,
    clearDisaster,
    retryTrigger,
    isProcessing: loading,
    hasActivePlan: !!plan,
    hasError: !!error,
    canRetry: retryCount < 2 && error,
  };
}

export default useDisaster;

Invalid Data Handling

Add validation utility src/utils/validation.js:

export function validateDisaster(disaster) {
  if (!disaster) return false;
  if (!disaster.disaster_id) return false;
  if (!disaster.location || !disaster.location.lat || !disaster.location.lon) return false;
  return true;
}

export function validatePlan(plan) {
  if (!plan) return false;
  if (!plan.disaster_id) return false;
  if (!plan.executive_summary) return false;
  return true;
}

export function sanitizeString(str) {
  if (typeof str !== 'string') return '';
  return str.replace(/<script[^>]*>.*?<\/script>/gi, '')
            .replace(/<[^>]+>/g, '')
            .trim();
}

export function sanitizeNumber(num, fallback = 0) {
  const parsed = Number(num);
  return isNaN(parsed) ? fallback : parsed;
}

Use validation in components:

import { validatePlan } from '../utils/validation';

function PlanViewer({ plan }) {
  if (!validatePlan(plan)) {
    return (
      <div className="plan-viewer error-state">
        <h2>Invalid Plan Data</h2>
        <p>The system generated invalid data. Please try again.</p>
      </div>
    );
  }
  
  // ... render plan
}

πŸ§ͺ Error Testing Scenarios

Test each scenario to ensure graceful handling:

SCENARIO 1: Backend Offline
1. Stop backend server
2. Try to trigger disaster
3. βœ“ Error message displays
4. βœ“ App doesn't crash
5. βœ“ Can dismiss error
6. βœ“ Retry option available

SCENARIO 2: WebSocket Disconnect
1. Start disaster processing
2. Disable network mid-process
3. βœ“ "Connection lost" message
4. βœ“ Progress bar pauses gracefully
5. βœ“ Re-enable network
6. βœ“ Reconnects automatically

SCENARIO 3: Invalid JSON Response
1. Mock API to return malformed JSON
2. Trigger disaster
3. βœ“ Error caught and displayed
4. βœ“ No console errors crash app
5. βœ“ Can recover

SCENARIO 4: Timeout
1. Mock slow API (130+ seconds)
2. Trigger disaster
3. βœ“ Timeout error after 2 minutes
4. βœ“ Clear error message
5. βœ“ Can retry

SCENARIO 5: Missing Map Token
1. Remove REACT_APP_MAPBOX_TOKEN
2. Load app
3. βœ“ Map shows error state
4. βœ“ Rest of app works
5. βœ“ Plan still displays

SCENARIO 6: Component Error
1. Force a React component error
2. βœ“ Error boundary catches it
3. βœ“ Shows friendly error page
4. βœ“ Can reload app
5. βœ“ Other features unaffected

πŸ“‹ Error Handling Checklist

  • Error boundaries catch React errors
  • API errors display user-friendly messages
  • WebSocket reconnection automatic
  • Timeout protection implemented
  • Invalid data validated
  • XSS protection (sanitization)
  • Retry logic works
  • Error logging to console
  • No uncaught exceptions
  • Tested all failure scenarios

⏱️ Estimated Time

90 minutes

πŸ”— Related Documentation

React Error Boundaries: https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions