Skip to content

Error Handling

itty-fetcher provides robust error handling that's far superior to native fetch, with multiple approaches to suit different coding styles.

Automatic Error Throwing

Unlike native fetch (which only rejects on network errors), itty-fetcher throws on HTTP error status codes by default:

ts
try {
  const data = await api.get('/nonexistent')
} catch (error) {
  console.log(error.status)    // 404
  console.log(error.message)   // "Not Found"
  console.log(error.response)  // Original Response object
}

Error Object Properties

Errors thrown by itty-fetcher include:

ts
interface FetcherError extends Error {
  status: number          // HTTP status code (404, 500, etc.)
  message: string         // Status text ("Not Found", "Internal Server Error")
  response: Response      // Original Response object
  // ... plus any JSON error body properties
}

JSON Error Bodies

When the server returns JSON error responses, those properties are merged into the error:

ts
// Server returns: { error: "Validation failed", field: "email" }
try {
  await api.post('/users', { name: 'Alice' })
} catch (error) {
  console.log(error.status)  // 400
  console.log(error.error)   // "Validation failed"
  console.log(error.field)   // "email"
}

Error Handling Approaches

1. Traditional Try/Catch

The standard approach for handling errors:

ts
async function fetchUser(id: string) {
  try {
    const user = await api.get(`/users/${id}`)
    return user
  } catch (error) {
    if (error.status === 404) {
      console.log('User not found')
      return null
    }

    if (error.status >= 500) {
      console.error('Server error:', error.message)
      throw error // Re-throw server errors
    }

    console.error('Request failed:', error.status, error.message)
    throw error
  }
}

2. Promise Catch

Chain error handling directly on promises:

ts
const user = await api.get('/users/123')
  .catch(error => {
    if (error.status === 404) return null
    throw error
  })

const result = await api.get('/maybe-missing')
  .catch(error => ({ error: error.status }))

if (result.error) {
  console.log('Request failed with status:', result.error)
}

3. Tuple Mode (Go-style)

For error handling without try/catch, use tuple mode:

ts
const api = fetcher({ array: true })

const [error, users] = await api.get('/users')
if (error) {
  console.log('Failed to fetch users:', error.status)
  return
}

console.log('Users fetched successfully:', users)

Tuple Mode Examples

ts
const api = fetcher({ array: true })

// Handle different error types
const [error, user] = await api.get('/users/123')
if (error) {
  switch (error.status) {
    case 404:
      console.log('User not found')
      break
    case 403:
      console.log('Access denied')
      break
    default:
      console.error('Unexpected error:', error.message)
  }
  return
}

// Successful response
console.log('User:', user)

Error Response Patterns

Handling Different Response Types

ts
// JSON error responses (most common)
try {
  await api.post('/users', invalidData)
} catch (error) {
  // error includes both status info AND JSON response data
  console.log(error.status)        // 400
  console.log(error.message)       // "Bad Request"
  console.log(error.errors)        // ["Email is required", "Name too short"]
}

// Text error responses
try {
  await api.get('/text-endpoint')
} catch (error) {
  console.log(error.status)        // 500
  console.log(error.message)       // "Internal Server Error"
  console.log(error.response)      // Response object with .text() method
}

// Empty error responses
try {
  await api.delete('/users/999')
} catch (error) {
  console.log(error.status)        // 404
  console.log(error.message)       // ""
  console.log(error.response)      // Response object
}

Graceful Degradation

ts
async function fetchUserWithFallback(id: string) {
  return await api.get(`/users/${id}`)
    .catch(error => {
      if (error.status === 404) {
        // Return default user for 404s
        return { id, name: 'Unknown User', email: null }
      }

      if (error.status >= 500) {
        // For server errors, return cached data if available
        return getCachedUser(id) || null
      }

      // For other errors, re-throw
      throw error
    })
}

Global Error Handling

Using Response Interceptors

Handle errors globally using the after option:

ts
const api = fetcher({
  after: [
    async (response) => {
      // Log all successful responses
      console.log('API Response:', response)
      return response
    }
  ]
})

// Note: `after` handlers only run on successful responses
// Errors are thrown before `after` handlers run

Wrapper Function Approach

Create a wrapper for consistent error handling:

ts
function createAPIWrapper(baseApi: Fetcher) {
  const wrapper = {
    async get(url: string, options?: any) {
      try {
        return await baseApi.get(url, options)
      } catch (error) {
        return handleError(error)
      }
    },

    async post(url: string, data?: any, options?: any) {
      try {
        return await baseApi.post(url, data, options)
      } catch (error) {
        return handleError(error)
      }
    }

    // ... other methods
  }

  function handleError(error: any) {
    // Global error logging
    console.error('API Error:', {
      status: error.status,
      message: error.message,
      url: error.response?.url
    })

    // Transform or re-throw as needed
    if (error.status === 401) {
      redirectToLogin()
      return null
    }

    throw error
  }

  return wrapper
}

const api = createAPIWrapper(fetcher('https://api.example.com'))

Testing Error Scenarios

ts
// Mock error responses for testing
const mockFetch = (status: number, body?: any) => {
  return Promise.resolve(new Response(
    body ? JSON.stringify(body) : null,
    { status, statusText: status === 404 ? 'Not Found' : 'Error' }
  ))
}

const api = fetcher({ fetch: mockFetch(404) })

try {
  await api.get('/test')
} catch (error) {
  expect(error.status).toBe(404)
  expect(error.message).toBe('Not Found')
}

Best Practices

  1. Be Specific: Handle different error status codes differently
  2. Fail Fast: Don't catch errors you can't handle meaningfully
  3. Log Appropriately: Include relevant context in error logs
  4. User Experience: Provide helpful error messages to users
  5. Retries: For transient errors, consider implementing retry logic outside of itty-fetcher
  6. Monitoring: Track error rates and patterns in production
ts
// Good error handling example
async function saveUser(userData: UserData) {
  try {
    return await api.post('/users', userData)
  } catch (error) {
    // Specific handling for validation errors
    if (error.status === 400) {
      throw new ValidationError(error.errors || ['Invalid user data'])
    }

    // Authentication/authorization
    if (error.status === 401) {
      throw new AuthError('Authentication required')
    }

    if (error.status === 403) {
      throw new AuthError('Permission denied')
    }

    // Server errors - log and provide generic message
    if (error.status >= 500) {
      console.error('Server error saving user:', error)
      throw new Error('Unable to save user. Please try again.')
    }

    // Unexpected errors
    console.error('Unexpected error:', error)
    throw error
  }
}