Error Handling - Exercise 3

The .catch() method handles rejected Promises. It receives the error as an argument and lets you recover from failures or log errors gracefully.

javascript

// Basic Promise with catch
fetch('https://api.example.com/data')
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => {
    console.error('Request failed:', error.message);
  });

// Catch can return a fallback value
const getData = () => {
  return fetch('https://api.example.com/users')
    .then(res => res.json())
    .catch(error => {
      console.error('Using fallback data');
      return []; // Return empty array as fallback
    });
};

// Chaining after catch continues the chain
Promise.reject(new Error('Failed'))
  .catch(err => 'Recovered')
  .then(result => console.log(result)); // "Recovered"

With async/await, you wrap your code in a try...catch block. The catch block receives any error thrown or rejected Promise, making async error handling look like synchronous code.

javascript

async function fetchUserData(userId) {
  try {
    const response = await fetch(`/api/users/${userId}`);
    
    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }
    
    const user = await response.json();
    return user;
  } catch (error) {
    console.error('Failed to fetch user:', error.message);
    return null; // Return fallback value
  }
}

// Using finally for cleanup
async function loadData() {
  const loader = showLoadingSpinner();
  try {
    const data = await fetchData();
    displayData(data);
  } catch (error) {
    showErrorMessage(error.message);
  } finally {
    loader.hide(); // Always runs
  }
}

Promise.allSettled() waits for all promises to complete, regardless of whether they resolve or reject. Each result has a status of "fulfilled" or "rejected" with its value or reason.

javascript

const promises = [
  fetch('/api/users'),
  fetch('/api/posts'),
  fetch('/api/comments')
];

const results = await Promise.allSettled(promises);

results.forEach((result, index) => {
  if (result.status === 'fulfilled') {
    console.log(`Promise ${index} succeeded:`, result.value);
  } else {
    console.log(`Promise ${index} failed:`, result.reason);
  }
});

// Filter successful and failed results
const successful = results.filter(r => r.status === 'fulfilled');
const failed = results.filter(r => r.status === 'rejected');

console.log(`${successful.length} succeeded, ${failed.length} failed`);

Unhandled promise rejections occur when a rejected Promise has no catch handler. Modern JavaScript environments provide events to catch these globally and prevent silent failures.

javascript

// In browsers: listen for unhandledrejection event
window.addEventListener('unhandledrejection', event => {
  console.error('Unhandled rejection:', event.reason);
  
  // Prevent default browser behavior (console error)
  event.preventDefault();
  
  // Log to your error tracking service
  logErrorToService({
    type: 'unhandled_rejection',
    error: event.reason,
    promise: event.promise
  });
});

// In Node.js
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise);
  console.error('Reason:', reason);
});

// Example that would trigger the handler
Promise.reject(new Error('Oops!')); // No .catch()

window.onerror is a global event handler that catches uncaught JavaScript errors. It receives error details including the message, source file, line number, and column number.

javascript

// Set up global error handler
window.onerror = function(message, source, lineno, colno, error) {
  console.error('Global error caught:');
  console.error('Message:', message);
  console.error('Source:', source);
  console.error('Line:', lineno, 'Column:', colno);
  console.error('Error object:', error);
  
  // Send to error tracking service
  reportError({
    message,
    source,
    lineno,
    colno,
    stack: error?.stack
  });
  
  // Return true to prevent default browser error handling
  return true;
};

// This error will be caught by window.onerror
function buggyCode() {
  undefinedVariable.doSomething(); // ReferenceError
}

The unhandledrejection event fires when a Promise rejects and no rejection handler is attached. This is your safety net for catching Promise errors that slip through.

javascript

// Add global handler for unhandled Promise rejections
window.addEventListener('unhandledrejection', function(event) {
  // The promise that was rejected
  const promise = event.promise;
  
  // The rejection reason (usually an Error)
  const reason = event.reason;
  
  console.error('Unhandled Promise Rejection:');
  console.error('Reason:', reason);
  
  // Log detailed error info
  if (reason instanceof Error) {
    console.error('Stack:', reason.stack);
  }
  
  // Prevent the default handling (error in console)
  event.preventDefault();
});

// Also handle when rejections are later handled
window.addEventListener('rejectionhandled', function(event) {
  console.log('A previously unhandled rejection is now handled');
});

Error boundaries are patterns that catch errors in a section of your application without crashing the entire app. They provide fallback UI and allow the rest of the application to continue working.

javascript

// Simple error boundary pattern for vanilla JavaScript
class ErrorBoundary {
  constructor(element, fallbackContent) {
    this.element = element;
    this.fallbackContent = fallbackContent;
  }
  
  wrap(fn) {
    try {
      return fn();
    } catch (error) {
      this.handleError(error);
      return null;
    }
  }
  
  async wrapAsync(fn) {
    try {
      return await fn();
    } catch (error) {
      this.handleError(error);
      return null;
    }
  }
  
  handleError(error) {
    console.error('Error caught by boundary:', error);
    this.element.innerHTML = this.fallbackContent;
    this.logError(error);
  }
  
  logError(error) {
    // Send to error tracking service
    fetch('/api/log-error', {
      method: 'POST',
      body: JSON.stringify({ error: error.message, stack: error.stack })
    });
  }
}

Logging errors to an external service helps you track and fix issues in production. You should capture the error message, stack trace, user context, and browser information.

javascript

// Error logging service
const ErrorLogger = {
  endpoint: '/api/errors',
  
  log(error, context = {}) {
    const errorData = {
      message: error.message,
      stack: error.stack,
      name: error.name,
      timestamp: new Date().toISOString(),
      url: window.location.href,
      userAgent: navigator.userAgent,
      ...context
    };
    
    // Use sendBeacon for reliability (works even during page unload)
    if (navigator.sendBeacon) {
      navigator.sendBeacon(this.endpoint, JSON.stringify(errorData));
    } else {
      fetch(this.endpoint, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(errorData),
        keepalive: true
      });
    }
  }
};

// Usage with try/catch
try {
  riskyOperation();
} catch (error) {
  ErrorLogger.log(error, { userId: currentUser.id, action: 'checkout' });
}

Fetch only rejects on network failures, not HTTP errors like 404 or 500. You must check response.ok and throw errors for non-success status codes to handle all failure cases properly.

javascript

async function fetchWithErrorHandling(url, options = {}) {
  try {
    const response = await fetch(url, options);
    
    // Check for HTTP errors (fetch does not reject on 4xx/5xx)
    if (!response.ok) {
      const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
      error.status = response.status;
      error.response = response;
      throw error;
    }
    
    return await response.json();
  } catch (error) {
    // Network error (no connection, DNS failure, etc.)
    if (error.name === 'TypeError') {
      console.error('Network error:', error.message);
      throw new Error('Unable to connect. Check your internet connection.');
    }
    
    // Timeout error
    if (error.name === 'AbortError') {
      throw new Error('Request timed out. Please try again.');
    }
    
    // Re-throw HTTP or other errors
    throw error;
  }
}

// Usage with timeout
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);

fetchWithErrorHandling('/api/data', { signal: controller.signal });

Retry logic automatically attempts failed operations again with delays between attempts. Use exponential backoff to increase wait times and prevent overwhelming servers.

javascript

async function retryWithBackoff(fn, maxRetries = 3, baseDelay = 1000) {
  let lastError;
  
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error;
      
      // Do not retry on certain errors
      if (error.status === 401 || error.status === 403) {
        throw error; // Auth errors should not retry
      }
      
      if (attempt < maxRetries - 1) {
        // Exponential backoff: 1s, 2s, 4s, ...
        const delay = baseDelay * Math.pow(2, attempt);
        console.log(`Attempt ${attempt + 1} failed. Retrying in ${delay}ms...`);
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  }
  
  throw lastError;
}

// Usage
const data = await retryWithBackoff(
  () => fetch('/api/data').then(r => r.json()),
  3,    // max 3 attempts
  1000  // start with 1 second delay
);

AggregateError is used when multiple errors need to be reported together. It is thrown by Promise.any() when all promises reject, and you can create it manually when validating multiple fields.

javascript

// AggregateError from Promise.any when all reject
const promises = [
  Promise.reject(new Error('First failed')),
  Promise.reject(new Error('Second failed')),
  Promise.reject(new Error('Third failed'))
];

try {
  await Promise.any(promises);
} catch (error) {
  if (error instanceof AggregateError) {
    console.log('All promises failed:');
    error.errors.forEach((err, i) => {
      console.log(`  ${i + 1}: ${err.message}`);
    });
  }
}

// Creating AggregateError manually for validation
function validateForm(data) {
  const errors = [];
  
  if (!data.email) errors.push(new Error('Email is required'));
  if (!data.password) errors.push(new Error('Password is required'));
  if (data.password?.length < 8) errors.push(new Error('Password too short'));
  
  if (errors.length > 0) {
    throw new AggregateError(errors, 'Form validation failed');
  }
}

Production error handling should be user friendly, secure, and informative for debugging. Never expose stack traces to users, always log errors, and provide graceful degradation.

javascript

// Best practices for production error handling
const ErrorHandler = {
  // 1. Use custom error classes for different error types
  isOperationalError(error) {
    return error.isOperational === true;
  },
  
  // 2. Show user friendly messages, log detailed info
  handle(error) {
    // Log full details for debugging
    console.error('[Error]', {
      message: error.message,
      stack: error.stack,
      timestamp: new Date().toISOString()
    });
    
    // Show friendly message to user
    const userMessage = this.isOperationalError(error)
      ? error.message
      : 'Something went wrong. Please try again later.';
    
    showNotification(userMessage, 'error');
  },
  
  // 3. Set up global handlers
  init() {
    window.onerror = (msg, src, line, col, err) => {
      this.handle(err || new Error(msg));
      return true;
    };
    
    window.addEventListener('unhandledrejection', event => {
      this.handle(event.reason);
      event.preventDefault();
    });
  }
};

// 4. Create operational errors with user messages
class AppError extends Error {
  constructor(message, statusCode = 500) {
    super(message);
    this.isOperational = true;
    this.statusCode = statusCode;
  }
}