TypeFetched/manual/chapter-5-error-handling.md
Casey Collier b85b9a63e2 Initial commit: TypedFetch - Zero-dependency, type-safe HTTP client
Features:
- Zero configuration, just works out of the box
- Runtime type inference and validation
- Built-in caching with W-TinyLFU algorithm
- Automatic retries with exponential backoff
- Circuit breaker for resilience
- Request deduplication
- Offline support with queue
- OpenAPI schema discovery
- Full TypeScript support with type descriptors
- Modular architecture
- Configurable for advanced use cases

Built with bun, ready for npm publishing
2025-07-20 12:35:43 -04:00

34 KiB
Raw Blame History

Chapter 5: Error Handling Like a Pro

"The difference between a good developer and a great developer is how they handle failure."


When Everything Goes Wrong

Sarah's Weather Buddy app was growing fast. Hundreds of users were saving their favorite cities and sharing dashboards. Then, one Friday afternoon, everything broke.

"The app is down!" her colleague Jake shouted. "I'm getting weird errors everywhere!"

Sarah looked at the error messages:

  • "NetworkError when attempting to fetch resource"
  • "Failed to fetch"
  • "TypeError: Cannot read property 'data' of undefined"

Not very helpful.

"This is why error handling matters," Marcus said, walking over. "Let's make your app bulletproof."

The Anatomy of API Errors

API errors come in many flavors, each requiring different handling:

// 1. Network Errors - Can't reach the server
try {
  await tf.get('https://api.example.com/data')
} catch (error) {
  if (error.code === 'NETWORK_ERROR') {
    console.log('Check your internet connection')
  }
}

// 2. HTTP Errors - Server says "no"
try {
  await tf.get('https://api.example.com/secret')
} catch (error) {
  if (error.response?.status === 401) {
    console.log('You need to login first')
  }
}

// 3. Timeout Errors - Server is too slow
try {
  await tf.get('https://slow-api.example.com/data', {
    timeout: 5000  // 5 seconds
  })
} catch (error) {
  if (error.code === 'TIMEOUT') {
    console.log('Server is taking too long')
  }
}

// 4. Parse Errors - Bad response format
try {
  await tf.get('https://api.example.com/broken')
} catch (error) {
  if (error.code === 'PARSE_ERROR') {
    console.log('Server sent invalid data')
  }
}

TypedFetch's Smart Error System

TypedFetch doesn't just catch errors - it helps you fix them:

try {
  const { data } = await tf.get('https://api.github.com/user')
} catch (error) {
  console.log(error.message)      // "Authentication required"
  console.log(error.suggestions)  // ["Add an Authorization header", "Get a token from Settings"]
  console.log(error.code)         // "AUTH_REQUIRED"
  console.log(error.status)       // 401
  
  // Debug mode gives even more info
  if (error.debug) {
    error.debug()
    // Outputs:
    // Request URL: https://api.github.com/user
    // Method: GET
    // Headers: { ... }
    // Response Status: 401 Unauthorized
    // Response Headers: { ... }
  }
}

HTTP Status Codes: What They Really Mean

The Good (2xx)

// 200 OK - Everything worked
const { data } = await tf.get('/api/users')

// 201 Created - New resource created
const { data: newUser } = await tf.post('/api/users', { 
  data: { name: 'Sarah' } 
})

// 204 No Content - Success, but no data to return
await tf.delete('/api/users/123')

The Redirects (3xx) 🔄

// TypedFetch follows redirects automatically
const { data, response } = await tf.get('/api/old-endpoint')
console.log(response.url)  // 'https://api.example.com/new-endpoint'

The Client Errors (4xx) 🚫

try {
  await tf.get('/api/users/999999')
} catch (error) {
  switch (error.response?.status) {
    case 400:
      console.error('Bad Request - Check your data')
      break
    case 401:
      console.error('Unauthorized - Login required')
      // Redirect to login
      window.location.href = '/login'
      break
    case 403:
      console.error('Forbidden - You don\'t have permission')
      break
    case 404:
      console.error('Not Found - Resource doesn\'t exist')
      break
    case 409:
      console.error('Conflict - Resource already exists')
      break
    case 422:
      console.error('Validation Error')
      console.error(error.data?.errors)  // Field-specific errors
      break
    case 429:
      console.error('Too Many Requests - Slow down!')
      const retryAfter = error.response.headers.get('Retry-After')
      console.log(`Try again in ${retryAfter} seconds`)
      break
  }
}

The Server Errors (5xx) 🔥

// These are usually temporary
try {
  await tf.get('/api/data')
} catch (error) {
  if (error.response?.status >= 500) {
    console.error('Server error - not your fault!')
    
    // TypedFetch automatically retries 5xx errors
    // But you can handle them manually too
    if (error.response.status === 503) {
      console.log('Service temporarily unavailable')
    }
  }
}

Retry Strategies: Never Give Up (Too Easily)

TypedFetch includes smart retry logic, but you can customize it:

// Default retry behavior (for 5xx and network errors)
const { data } = await tf.get('/api/flaky-endpoint')

// Custom retry configuration
const { data } = await tf.get('/api/important-data', {
  retry: {
    attempts: 5,              // Try 5 times (default: 3)
    delay: 1000,             // Start with 1 second delay
    maxDelay: 30000,         // Max 30 seconds between retries
    backoff: 'exponential',  // Double delay each time
    retryOn: [500, 502, 503, 504, 'NETWORK_ERROR', 'TIMEOUT']
  }
})

// Disable retries
const { data } = await tf.get('/api/no-retry', {
  retry: false
})

// Manual retry with exponential backoff
async function fetchWithRetry(url, maxAttempts = 3) {
  let lastError
  
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await tf.get(url)
    } catch (error) {
      lastError = error
      
      // Don't retry client errors
      if (error.response?.status >= 400 && error.response?.status < 500) {
        throw error
      }
      
      if (attempt < maxAttempts) {
        // Exponential backoff with jitter
        const delay = Math.min(1000 * Math.pow(2, attempt - 1), 30000)
        const jitter = Math.random() * 0.3 * delay
        
        console.log(`Retry ${attempt}/${maxAttempts} in ${delay + jitter}ms`)
        await new Promise(resolve => setTimeout(resolve, delay + jitter))
      }
    }
  }
  
  throw lastError
}

Circuit Breaker Pattern: Fail Fast

When a service is down, stop hammering it:

// TypedFetch includes a built-in circuit breaker
const { data } = await tf.get('/api/unreliable-service')

// If too many requests fail, the circuit "opens"
// and TypedFetch will fail fast without making requests

// You can check circuit status
const metrics = tf.getMetrics()
console.log(metrics.circuitBreaker)
// {
//   '/api/unreliable-service': {
//     state: 'open',
//     failures: 10,
//     lastFailure: '2024-01-20T15:30:00Z',
//     nextRetry: '2024-01-20T15:35:00Z'
//   }
// }

// Reset a tripped circuit manually
tf.resetCircuitBreaker('/api/unreliable-service')

// Or implement your own circuit breaker
class CircuitBreaker {
  constructor(threshold = 5, timeout = 60000) {
    this.threshold = threshold
    this.timeout = timeout
    this.failures = 0
    this.lastFailureTime = null
    this.state = 'closed'  // closed, open, half-open
  }
  
  async execute(fn) {
    if (this.state === 'open') {
      if (Date.now() - this.lastFailureTime > this.timeout) {
        this.state = 'half-open'
      } else {
        throw new Error('Circuit breaker is open')
      }
    }
    
    try {
      const result = await fn()
      this.onSuccess()
      return result
    } catch (error) {
      this.onFailure()
      throw error
    }
  }
  
  onSuccess() {
    this.failures = 0
    this.state = 'closed'
  }
  
  onFailure() {
    this.failures++
    this.lastFailureTime = Date.now()
    
    if (this.failures >= this.threshold) {
      this.state = 'open'
      console.log('Circuit breaker opened!')
    }
  }
}

// Usage
const breaker = new CircuitBreaker()
try {
  const data = await breaker.execute(() => tf.get('/api/flaky'))
} catch (error) {
  console.error('Service unavailable')
}

User-Friendly Error Messages

Turn technical errors into helpful messages:

function getUserMessage(error) {
  // Network errors
  if (error.code === 'NETWORK_ERROR') {
    return {
      title: 'Connection Problem',
      message: 'Please check your internet connection and try again.',
      icon: '📡',
      actions: [
        { label: 'Retry', action: 'retry' },
        { label: 'Work Offline', action: 'offline' }
      ]
    }
  }
  
  // Authentication errors
  if (error.response?.status === 401) {
    return {
      title: 'Please Sign In',
      message: 'You need to sign in to access this feature.',
      icon: '🔒',
      actions: [
        { label: 'Sign In', action: 'login' },
        { label: 'Create Account', action: 'register' }
      ]
    }
  }
  
  // Rate limiting
  if (error.response?.status === 429) {
    const retryAfter = error.response.headers.get('Retry-After')
    return {
      title: 'Slow Down!',
      message: `You're making too many requests. Please wait ${retryAfter} seconds.`,
      icon: '⏱️',
      countdown: parseInt(retryAfter)
    }
  }
  
  // Server errors
  if (error.response?.status >= 500) {
    return {
      title: 'Server Problem',
      message: 'Something went wrong on our end. We\'re working on it!',
      icon: '🔧',
      actions: [
        { label: 'Try Again', action: 'retry' },
        { label: 'Report Issue', action: 'report' }
      ]
    }
  }
  
  // Generic error
  return {
    title: 'Something Went Wrong',
    message: error.message || 'An unexpected error occurred.',
    icon: '❌',
    actions: [
      { label: 'Try Again', action: 'retry' }
    ]
  }
}

// React component example
function ErrorDisplay({ error, onAction }) {
  const userError = getUserMessage(error)
  
  return (
    <div className="error-container">
      <div className="error-icon">{userError.icon}</div>
      <h3>{userError.title}</h3>
      <p>{userError.message}</p>
      
      {userError.countdown && (
        <CountdownTimer seconds={userError.countdown} />
      )}
      
      <div className="error-actions">
        {userError.actions?.map(action => (
          <button 
            key={action.action}
            onClick={() => onAction(action.action)}
          >
            {action.label}
          </button>
        ))}
      </div>
    </div>
  )
}

Weather Buddy 5.0: Bulletproof Edition

Let's make Weather Buddy handle every possible failure:

<!DOCTYPE html>
<html>
<head>
    <title>Weather Buddy 5.0 - Bulletproof Edition</title>
    <style>
        body { font-family: Arial, sans-serif; margin: 20px; }
        .city-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; }
        .city-card { border: 1px solid #ddd; padding: 15px; border-radius: 8px; }
        .city-card.error { border-color: #ff4444; background: #fff0f0; }
        .city-card.loading { opacity: 0.6; }
        .city-card.offline { border-color: #ffaa00; background: #fffaf0; }
        .error-message { color: #ff4444; padding: 10px; background: #fff0f0; border-radius: 4px; margin: 10px 0; }
        .retry-button { background: #4CAF50; color: white; border: none; padding: 5px 10px; cursor: pointer; }
        .status-bar { position: fixed; top: 0; left: 0; right: 0; padding: 10px; text-align: center; }
        .status-bar.online { background: #4CAF50; color: white; }
        .status-bar.offline { background: #ff9800; color: white; }
        .circuit-status { position: fixed; bottom: 20px; right: 20px; padding: 10px; background: white; border: 1px solid #ddd; border-radius: 4px; }
    </style>
    <script type="module">
        import { tf } from 'https://esm.sh/typedfetch'
        
        // Global error tracking
        const errorStats = new Map()
        const retryQueues = new Map()
        let isOnline = navigator.onLine
        
        // Configure TypedFetch with custom error handling
        tf.addResponseInterceptor(response => {
            // Track successful requests
            if (response.config?.url) {
                errorStats.delete(response.config.url)
            }
            return response
        })
        
        tf.addRequestInterceptor(config => {
            // Add request ID for tracking
            config.headers = {
                ...config.headers,
                'X-Request-ID': crypto.randomUUID()
            }
            return config
        })
        
        // Network status monitoring
        window.addEventListener('online', () => {
            isOnline = true
            updateNetworkStatus()
            processRetryQueue()
        })
        
        window.addEventListener('offline', () => {
            isOnline = false
            updateNetworkStatus()
        })
        
        function updateNetworkStatus() {
            const statusBar = document.getElementById('statusBar')
            if (isOnline) {
                statusBar.className = 'status-bar online'
                statusBar.textContent = '🟢 Online'
            } else {
                statusBar.className = 'status-bar offline'
                statusBar.textContent = '🔴 Offline - Working in offline mode'
            }
        }
        
        // Smart error handling with recovery
        async function fetchWeatherWithRecovery(city, retryCount = 0) {
            const cardId = `city-${city.replace(/\s/g, '-')}`
            const card = document.getElementById(cardId)
            
            try {
                // Show loading state
                card.classList.add('loading')
                card.classList.remove('error', 'offline')
                
                // Check if we're offline
                if (!isOnline) {
                    throw { code: 'OFFLINE', message: 'No internet connection' }
                }
                
                // Attempt to fetch weather
                const { data } = await tf.get(`https://wttr.in/${city}?format=j1`, {
                    timeout: 10000,
                    retry: {
                        attempts: 3,
                        delay: 1000,
                        backoff: 'exponential'
                    }
                })
                
                // Success! Update the card
                updateWeatherCard(card, city, data)
                
                // Clear any error tracking
                errorStats.delete(city)
                
            } catch (error) {
                console.error(`Weather fetch failed for ${city}:`, error)
                
                // Track errors
                const errorCount = (errorStats.get(city) || 0) + 1
                errorStats.set(city, errorCount)
                
                // Handle different error types
                handleWeatherError(card, city, error, errorCount, retryCount)
            } finally {
                card.classList.remove('loading')
            }
        }
        
        function handleWeatherError(card, city, error, errorCount, retryCount) {
            let errorDisplay = {
                icon: '❌',
                title: 'Error',
                message: 'Something went wrong',
                actions: []
            }
            
            // Offline error
            if (error.code === 'OFFLINE' || !navigator.onLine) {
                card.classList.add('offline')
                errorDisplay = {
                    icon: '📡',
                    title: 'Offline',
                    message: 'Waiting for connection...',
                    actions: []
                }
                
                // Queue for retry when online
                if (!retryQueues.has(city)) {
                    retryQueues.set(city, () => fetchWeatherWithRecovery(city))
                }
            }
            
            // Network error
            else if (error.code === 'NETWORK_ERROR') {
                errorDisplay = {
                    icon: '🌐',
                    title: 'Connection Problem',
                    message: 'Check your internet',
                    actions: [{ label: 'Retry', action: () => fetchWeatherWithRecovery(city) }]
                }
            }
            
            // Timeout
            else if (error.code === 'TIMEOUT') {
                errorDisplay = {
                    icon: '⏱️',
                    title: 'Too Slow',
                    message: 'Server took too long',
                    actions: [{ label: 'Try Again', action: () => fetchWeatherWithRecovery(city) }]
                }
            }
            
            // Rate limiting
            else if (error.response?.status === 429) {
                const retryAfter = error.response.headers.get('Retry-After') || 60
                errorDisplay = {
                    icon: '🚦',
                    title: 'Rate Limited',
                    message: `Wait ${retryAfter}s`,
                    countdown: parseInt(retryAfter)
                }
                
                // Auto-retry after cooldown
                setTimeout(() => fetchWeatherWithRecovery(city), retryAfter * 1000)
            }
            
            // Server errors - implement exponential backoff
            else if (error.response?.status >= 500) {
                const nextRetry = Math.min(Math.pow(2, retryCount) * 1000, 60000)
                errorDisplay = {
                    icon: '🔧',
                    title: 'Server Issue',
                    message: `Retry in ${Math.round(nextRetry/1000)}s`,
                    actions: [{ label: 'Retry Now', action: () => fetchWeatherWithRecovery(city, retryCount + 1) }]
                }
                
                // Auto-retry with backoff
                setTimeout(() => fetchWeatherWithRecovery(city, retryCount + 1), nextRetry)
            }
            
            // Not found
            else if (error.response?.status === 404) {
                card.classList.add('error')
                errorDisplay = {
                    icon: '🔍',
                    title: 'City Not Found',
                    message: 'Check the city name',
                    actions: [{ label: 'Remove', action: () => removeCity(city) }]
                }
            }
            
            // Update card with error display
            card.innerHTML = `
                <h3>${city}</h3>
                <div class="error-display">
                    <div style="font-size: 48px; text-align: center;">${errorDisplay.icon}</div>
                    <h4>${errorDisplay.title}</h4>
                    <p>${errorDisplay.message}</p>
                    ${errorDisplay.countdown ? `<div class="countdown" data-seconds="${errorDisplay.countdown}"></div>` : ''}
                    <div class="error-actions">
                        ${errorDisplay.actions.map(action => 
                            `<button class="retry-button" onclick="window.errorActions['${city}']()">
                                ${action.label}
                            </button>`
                        ).join('')}
                    </div>
                </div>
                <div class="error-stats">
                    <small>Errors: ${errorCount} | Circuit: ${getCircuitStatus(city)}</small>
                </div>
            `
            
            // Store action handlers
            if (!window.errorActions) window.errorActions = {}
            if (errorDisplay.actions[0]) {
                window.errorActions[city] = errorDisplay.actions[0].action
            }
            
            // Start countdown if needed
            if (errorDisplay.countdown) {
                startCountdown(card.querySelector('.countdown'), errorDisplay.countdown)
            }
        }
        
        function updateWeatherCard(card, city, weather) {
            card.classList.remove('error', 'offline')
            card.innerHTML = `
                <h3>${city}</h3>
                <button onclick="removeCity('${city}')" style="float: right">×</button>
                <p>🌡️ ${weather.current_condition[0].temp_C}°C / ${weather.current_condition[0].temp_F}°F</p>
                <p>🌤️ ${weather.current_condition[0].weatherDesc[0].value}</p>
                <p>💨 Wind: ${weather.current_condition[0].windspeedKmph} km/h</p>
                <p>💧 Humidity: ${weather.current_condition[0].humidity}%</p>
                <p>🔄 Updated: ${new Date().toLocaleTimeString()}</p>
                <button class="retry-button" onclick="window.fetchWeatherWithRecovery('${city}')">
                    Refresh
                </button>
            `
        }
        
        function processRetryQueue() {
            console.log('Processing retry queue...')
            for (const [city, retryFn] of retryQueues.entries()) {
                retryFn()
                retryQueues.delete(city)
            }
        }
        
        function getCircuitStatus(endpoint) {
            const metrics = tf.getMetrics()
            const circuit = metrics.circuitBreaker?.[endpoint]
            if (!circuit) return 'closed'
            return circuit.state
        }
        
        function updateCircuitDisplay() {
            const display = document.getElementById('circuitStatus')
            const metrics = tf.getMetrics()
            const circuits = metrics.circuitBreaker || {}
            
            const html = Object.entries(circuits)
                .filter(([_, data]) => data.state !== 'closed')
                .map(([endpoint, data]) => `
                    <div>
                        <strong>${endpoint}</strong>: ${data.state}
                        <br>Failures: ${data.failures}
                    </div>
                `).join('')
            
            display.innerHTML = html || '<div>All circuits healthy ✅</div>'
        }
        
        function startCountdown(element, seconds) {
            let remaining = seconds
            const update = () => {
                element.textContent = `Retry in ${remaining}s`
                if (remaining > 0) {
                    remaining--
                    setTimeout(update, 1000)
                }
            }
            update()
        }
        
        // Global functions
        window.fetchWeatherWithRecovery = fetchWeatherWithRecovery
        window.addCity = async function(cityName) {
            const cityDiv = document.createElement('div')
            cityDiv.className = 'city-card'
            cityDiv.id = `city-${cityName.replace(/\s/g, '-')}`
            document.getElementById('cityGrid').appendChild(cityDiv)
            
            await fetchWeatherWithRecovery(cityName)
        }
        
        window.removeCity = function(cityName) {
            document.getElementById(`city-${cityName.replace(/\s/g, '-')}`).remove()
            errorStats.delete(cityName)
            retryQueues.delete(cityName)
        }
        
        // Initialize
        window.addEventListener('load', () => {
            updateNetworkStatus()
            
            // Add default cities
            ['London', 'Tokyo', 'New York'].forEach(city => addCity(city))
            
            // Update circuit breaker display
            setInterval(updateCircuitDisplay, 2000)
            
            // Enable debug mode for development
            if (location.hostname === 'localhost') {
                tf.enableDebug()
            }
        })
    </script>
</head>
<body>
    <div id="statusBar" class="status-bar online">🟢 Online</div>
    
    <h1>Weather Buddy 5.0 - Bulletproof Edition 🛡️</h1>
    
    <div class="search-box">
        <input 
            type="text" 
            id="citySearch" 
            placeholder="Add a city..."
            onkeypress="if(event.key==='Enter') addCity(this.value)"
        />
        <button onclick="addCity(document.getElementById('citySearch').value)">Add City</button>
    </div>
    
    <div id="cityGrid" class="city-grid"></div>
    
    <div id="circuitStatus" class="circuit-status">
        <strong>Circuit Breakers</strong>
        <div>All circuits healthy ✅</div>
    </div>
</body>
</html>

Advanced Error Handling Patterns

1. Graceful Degradation

Fall back to cached or default data:

async function getWeatherWithFallback(city) {
  try {
    // Try live data first
    const { data } = await tf.get(`/api/weather/${city}`)
    return { data, source: 'live' }
  } catch (error) {
    // Try cache
    const cached = await getCachedWeather(city)
    if (cached) {
      return { data: cached, source: 'cache' }
    }
    
    // Use historical average
    const historical = await getHistoricalWeather(city)
    if (historical) {
      return { data: historical, source: 'historical' }
    }
    
    // Last resort: estimated data
    return {
      data: {
        temp: 20,
        condition: 'unknown',
        message: 'Unable to get current weather'
      },
      source: 'estimated'
    }
  }
}

2. Error Boundaries

Contain errors to prevent app crashes:

// React error boundary
class ApiErrorBoundary extends React.Component {
  state = { hasError: false, error: null }
  
  static getDerivedStateFromError(error) {
    return { hasError: true, error }
  }
  
  componentDidCatch(error, errorInfo) {
    console.error('API Error:', error, errorInfo)
    
    // Report to error tracking service
    if (window.errorReporter) {
      window.errorReporter.log(error, {
        component: 'ApiErrorBoundary',
        ...errorInfo
      })
    }
  }
  
  render() {
    if (this.state.hasError) {
      return (
        <ErrorFallback
          error={this.state.error}
          resetError={() => this.setState({ hasError: false })}
        />
      )
    }
    
    return this.props.children
  }
}

// Vue error handler
app.config.errorHandler = (err, instance, info) => {
  if (err.code?.startsWith('API_')) {
    // Handle API errors specially
    showApiError(err)
  } else {
    // Let other errors bubble up
    throw err
  }
}

3. Error Recovery Strategies

class ErrorRecovery {
  constructor() {
    this.strategies = new Map()
  }
  
  register(errorType, strategy) {
    this.strategies.set(errorType, strategy)
  }
  
  async handle(error, context) {
    // Try specific strategies first
    const strategy = this.strategies.get(error.code) ||
                    this.strategies.get(error.response?.status) ||
                    this.strategies.get('default')
    
    if (strategy) {
      return await strategy(error, context)
    }
    
    throw error
  }
}

// Register recovery strategies
const recovery = new ErrorRecovery()

recovery.register('NETWORK_ERROR', async (error, { retry }) => {
  // Wait for connection
  await waitForNetwork()
  return retry()
})

recovery.register(401, async (error, { refresh }) => {
  // Try refreshing auth token
  const newToken = await refreshAuthToken()
  if (newToken) {
    return refresh()
  }
  throw error
})

recovery.register(429, async (error, { retry }) => {
  // Honor rate limit
  const retryAfter = error.response.headers.get('Retry-After') || 60
  await sleep(retryAfter * 1000)
  return retry()
})

// Usage
async function apiCallWithRecovery(url) {
  const context = {
    retry: () => tf.get(url),
    refresh: () => tf.get(url)
  }
  
  try {
    return await tf.get(url)
  } catch (error) {
    return await recovery.handle(error, context)
  }
}

4. Error Aggregation and Reporting

class ErrorReporter {
  constructor() {
    this.errors = []
    this.threshold = 10
    this.window = 60000  // 1 minute
  }
  
  report(error) {
    const now = Date.now()
    
    // Add to error list
    this.errors.push({
      error,
      timestamp: now,
      url: error.config?.url,
      status: error.response?.status
    })
    
    // Clean old errors
    this.errors = this.errors.filter(e => 
      now - e.timestamp < this.window
    )
    
    // Check if we should alert
    if (this.errors.length >= this.threshold) {
      this.sendAlert()
    }
  }
  
  sendAlert() {
    const summary = this.summarizeErrors()
    
    // Send to monitoring service
    fetch('/api/monitoring/errors', {
      method: 'POST',
      body: JSON.stringify(summary)
    })
    
    // Show user warning
    if (summary.criticalCount > 5) {
      showSystemWarning('Multiple services are experiencing issues')
    }
  }
  
  summarizeErrors() {
    const byEndpoint = new Map()
    const byStatus = new Map()
    
    for (const error of this.errors) {
      // Group by endpoint
      const endpoint = error.url || 'unknown'
      byEndpoint.set(endpoint, (byEndpoint.get(endpoint) || 0) + 1)
      
      // Group by status
      const status = error.status || 'network'
      byStatus.set(status, (byStatus.get(status) || 0) + 1)
    }
    
    return {
      total: this.errors.length,
      byEndpoint: Object.fromEntries(byEndpoint),
      byStatus: Object.fromEntries(byStatus),
      criticalCount: byStatus.get(500) || 0
    }
  }
}

// Global error reporter
const errorReporter = new ErrorReporter()

// Intercept all TypedFetch errors
tf.addResponseInterceptor(response => {
  if (response.error) {
    errorReporter.report(response.error)
  }
  return response
})

Testing Error Scenarios

Always test how your app handles failures:

// Mock different error scenarios
function createErrorMock(status, message) {
  return {
    get: async () => {
      const error = new Error(message)
      error.response = { status }
      throw error
    }
  }
}

// Test suite
describe('Error Handling', () => {
  test('handles network errors', async () => {
    const mock = createErrorMock(null, 'Network error')
    const result = await handleApiCall(mock)
    expect(result.fallback).toBe(true)
  })
  
  test('handles auth errors', async () => {
    const mock = createErrorMock(401, 'Unauthorized')
    const result = await handleApiCall(mock)
    expect(result.redirectedToLogin).toBe(true)
  })
  
  test('handles rate limits', async () => {
    const mock = createErrorMock(429, 'Too many requests')
    const start = Date.now()
    const result = await handleApiCall(mock)
    const duration = Date.now() - start
    expect(duration).toBeGreaterThan(1000)  // Waited
  })
})

Best Practices for Error Handling 🎯

1. Be Specific

// ❌ Bad: Generic error
catch (error) {
  alert('Error occurred')
}

// ✅ Good: Specific handling
catch (error) {
  if (error.code === 'NETWORK_ERROR') {
    showOfflineMessage()
  } else if (error.response?.status === 401) {
    redirectToLogin()
  } else {
    showErrorDetails(error)
  }
}

2. Provide Solutions

// ❌ Bad: Just showing the error
showError('Request failed')

// ✅ Good: Actionable message
showError({
  message: 'Unable to save your changes',
  suggestions: [
    'Check your internet connection',
    'Try refreshing the page',
    'Contact support if the problem persists'
  ],
  actions: [
    { label: 'Retry', onClick: retry },
    { label: 'Save Offline', onClick: saveOffline }
  ]
})

3. Log Everything

// Comprehensive error logging
function logError(error, context) {
  const errorData = {
    timestamp: new Date().toISOString(),
    message: error.message,
    code: error.code,
    status: error.response?.status,
    url: error.config?.url,
    method: error.config?.method,
    requestId: error.config?.headers?.['X-Request-ID'],
    userId: getCurrentUserId(),
    context,
    stack: error.stack,
    userAgent: navigator.userAgent
  }
  
  // Local logging
  console.error('API Error:', errorData)
  
  // Remote logging (if online)
  if (navigator.onLine) {
    sendToLoggingService(errorData)
  } else {
    queueForLaterLogging(errorData)
  }
}

4. Fail Gracefully

// Always have a fallback
async function getResilientData(endpoint) {
  const strategies = [
    () => tf.get(endpoint),                      // Live data
    () => tf.get(endpoint, { cache: 'force' }), // Force cache
    () => getFromLocalStorage(endpoint),         // Local storage
    () => getDefaultData(endpoint)               // Default data
  ]
  
  for (const strategy of strategies) {
    try {
      return await strategy()
    } catch (error) {
      continue  // Try next strategy
    }
  }
  
  // All strategies failed
  return { data: null, error: true }
}

Practice Time! 🏋️

Exercise 1: Custom Error Handler

Build a comprehensive error handler:

class ApiErrorHandler {
  // Your code here:
  // - Handle different error types
  // - Implement retry logic
  // - Track error patterns
  // - Provide user feedback
}

Exercise 2: Circuit Breaker Implementation

Create a circuit breaker from scratch:

class CircuitBreaker {
  // Your code here:
  // - Track failures
  // - Open circuit on threshold
  // - Half-open state
  // - Auto-recovery
}

Exercise 3: Offline Queue

Build a queue for offline requests:

class OfflineQueue {
  // Your code here:
  // - Queue failed requests
  // - Persist to localStorage
  // - Retry when online
  // - Handle conflicts
}

Key Takeaways 🎯

  1. Errors are inevitable - Plan for them from the start
  2. Different errors need different handling - Network vs 4xx vs 5xx
  3. TypedFetch provides smart errors - Use the suggestions
  4. Retry with exponential backoff - Don't hammer failing services
  5. Circuit breakers prevent cascading failures - Fail fast when needed
  6. User-friendly messages matter - Turn tech errors into helpful guidance
  7. Always have a fallback - Cached data is better than no data
  8. Test error scenarios - They happen more than success cases

Common Pitfalls 🚨

  1. Swallowing errors silently - Always inform the user
  2. Infinite retry loops - Set max attempts
  3. Not respecting rate limits - Honor Retry-After headers
  4. Generic error messages - Be specific and helpful
  5. No offline handling - Apps should work without internet
  6. Missing error boundaries - One error shouldn't crash everything

What's Next?

You're now an error-handling expert! But what about making your app fast? In Chapter 6, we'll explore TypedFetch's revolutionary caching system:

  • The W-TinyLFU algorithm that's 25% better than LRU
  • Automatic cache management
  • Cache warming strategies
  • Invalidation patterns
  • Offline-first architecture

Ready to make your app lightning fast? See you in Chapter 6!


Chapter Summary

  • API errors come in many forms: network, HTTP status codes, timeouts, parsing
  • TypedFetch provides enhanced errors with messages, suggestions, and debug info
  • HTTP status codes tell you exactly what went wrong (4xx = your fault, 5xx = server's fault)
  • Implement retry strategies with exponential backoff for transient failures
  • Circuit breakers prevent cascading failures by failing fast
  • Turn technical errors into user-friendly messages with actionable solutions
  • Always have fallback strategies: cache, local storage, or default data
  • Weather Buddy 5.0 handles every error gracefully with retries, offline support, and clear feedback

Next Chapter Preview: The Cache Revolution - How TypedFetch's W-TinyLFU algorithm makes your app incredibly fast while using less memory than traditional caches.