ffetch/docs/examples.md at main · fetch-kit/ffetch

Usage Examples

Real-world examples and patterns for using @fetchkit/ffetch in different scenarios.

Basic Usage Patterns

Checking Circuit State Before Request

You can inspect the circuit breaker state at runtime using client.circuitOpen to avoid making requests when the circuit is open:

import { createClient } from '@fetchkit/ffetch'
import { circuitPlugin } from '@fetchkit/ffetch/plugins/circuit'

const client = createClient({
  plugins: [circuitPlugin({ threshold: 5, reset: 30000 })],
})

if (client.circuitOpen) {
  console.warn('Service is unavailable (circuit open). Skipping request.')
} else {
  const response = await client('https://api.example.com/data')
  const data = await response.json()
}

// client.circuitOpen is available when circuitPlugin(...) is installed on the client.

Simple HTTP Client

import { createClient } from '@fetchkit/ffetch'

const api = createClient({
  timeout: 10000,
  retries: 2,
})

// GET request
const users = await api('https://api.example.com/users').then((r) => r.json())

// POST request
const newUser = await api('https://api.example.com/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'John', email: 'john@example.com' }),
}).then((r) => r.json())

REST API Client

import { createClient, type FFetch } from '@fetchkit/ffetch'

class ApiClient {
  private client: FFetch
  private baseUrl: string

  constructor(baseUrl: string, apiKey: string) {
    this.baseUrl = baseUrl.replace(/\/$/, '') // Remove trailing slash
    this.client = createClient({
      timeout: 15000,
      retries: 3,
      hooks: {
        transformRequest: async (req) => {
          // Build full URL from base + path
          const fullUrl = new URL(req.url, this.baseUrl).toString()

          return new Request(fullUrl, {
            method: req.method,
            headers: {
              ...Object.fromEntries(req.headers),
              Authorization: `Bearer ${apiKey}`,
              'Content-Type': 'application/json',
            },
            body: req.body,
            signal: req.signal,
          })
        },
      },
    })
  }

  async get<T>(path: string): Promise<T> {
    const response = await this.client(path)
    return response.json()
  }

  async post<T>(path: string, data: any): Promise<T> {
    const response = await this.client(path, {
      method: 'POST',
      body: JSON.stringify(data),
    })
    return response.json()
  }

  async put<T>(path: string, data: any): Promise<T> {
    const response = await this.client(path, {
      method: 'PUT',
      body: JSON.stringify(data),
    })
    return response.json()
  }

  async delete(path: string): Promise<void> {
    await this.client(path, { method: 'DELETE' })
  }
}

// Usage
const api = new ApiClient('https://api.example.com/v1', process.env.API_KEY!)
const users = await api.get<User[]>('/users')
const newUser = await api.post<User>('/users', { name: 'John' })

Custom Fetch Usage

Using ffetch with a Custom Fetch (e.g., node-fetch)

import { createClient } from '@fetchkit/ffetch'
import fetch from 'node-fetch'

const client = createClient({ fetchHandler: fetch })
const response = await client('https://api.example.com/data')
const data = await response.json()

Injecting a Mock Fetch for Unit Tests

You can provide a mock fetch handler at the client level or override it per-request:

import { createClient } from '@fetchkit/ffetch'

function mockFetch(url, options) {
  return Promise.resolve(
    new Response(JSON.stringify({ ok: true, url }), { status: 200 })
  )
}

// Option 1: Client-level fetchHandler (all requests use this)
const client = createClient({ fetchHandler: mockFetch })
const response = await client('https://api.example.com/test')
const data = await response.json()
// data: { ok: true, url: 'https://api.example.com/test' }

// Option 2: Per-request fetchHandler (useful for different mocks per test)
const client2 = createClient({ retries: 0 })

// First request with specific mock
const mockUser = () =>
  Promise.resolve(
    new Response(JSON.stringify({ id: 1, name: 'Alice' }), { status: 200 })
  )
const userResponse = await client2('/api/user', { fetchHandler: mockUser })
// Returns: { id: 1, name: 'Alice' }

// Second request with different mock
const mockPosts = () =>
  Promise.resolve(
    new Response(JSON.stringify([{ id: 1, title: 'Hello' }]), { status: 200 })
  )
const postsResponse = await client2('/api/posts', { fetchHandler: mockPosts })
// Returns: [{ id: 1, title: 'Hello' }]

Advanced Patterns

Microservices Client

import { createClient, type FFetch } from '@fetchkit/ffetch'

interface ServiceConfig {
  baseUrl: string
  timeout?: number
  retries?: number
  circuitBreaker?: { threshold: number; reset: number }
}

class MicroserviceClient {
  private clients = new Map<string, FFetch>()

  constructor(private services: Record<string, ServiceConfig>) {}

  private getClient(serviceName: string): FFetch {
    if (!this.clients.has(serviceName)) {
      const config = this.services[serviceName]
      if (!config) {
        throw new Error(`Service ${serviceName} not configured`)
      }

      const client = createClient({
        timeout: config.timeout || 5000,
        retries: config.retries || 2,
        plugins: config.circuitBreaker
          ? [
              circuitPlugin({
                threshold: config.circuitBreaker.threshold,
                reset: config.circuitBreaker.reset,
              }),
            ]
          : [],
        hooks: {
          transformRequest: async (req) => {
            // Properly construct full URL
            const fullUrl = new URL(req.url, config.baseUrl).toString()
            return new Request(fullUrl, {
              method: req.method,
              headers: req.headers,
              body: req.body,
              signal: req.signal,
            })
          },
          before: async (req) => {
            console.log(`[${serviceName}] →`, req.method, req.url)
          },
          after: async (req, res) => {
            console.log(`[${serviceName}] ←`, res.status)
          },
          onError: async (req, err) => {
            console.error(`[${serviceName}] ✗`, err.message)
          },
        },
      })

      this.clients.set(serviceName, client)
    }

    return this.clients.get(serviceName)!
  }

  async call(serviceName: string, path: string, options?: RequestInit) {
    const client = this.getClient(serviceName)
    return client(path, options)
  }

  // Service-specific methods
  async getUser(id: string) {
    return this.call('users', `/users/${id}`).then((r) => r.json())
  }

  async createOrder(order: any) {
    return this.call('orders', '/orders', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(order),
    }).then((r) => r.json())
  }

  async processPayment(payment: any) {
    return this.call('payments', '/process', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payment),
    }).then((r) => r.json())
  }
}

// Configuration
const client = new MicroserviceClient({
  users: {
    baseUrl: 'https://users.service.com/api',
    timeout: 3000,
    retries: 2,
  },
  orders: {
    baseUrl: 'https://orders.service.com/api',
    timeout: 5000,
    retries: 3,
    circuitBreaker: { threshold: 5, reset: 30000 },
  },
  payments: {
    baseUrl: 'https://payments.service.com/api',
    timeout: 10000,
    retries: 1,
    circuitBreaker: { threshold: 3, reset: 60000 },
  },
})

GraphQL Client

import { createClient, type FFetch } from '@fetchkit/ffetch'

class GraphQLClient {
  private client: FFetch

  constructor(
    private endpoint: string,
    headers: Record<string, string> = {}
  ) {
    this.client = createClient({
      timeout: 30000,
      retries: 2,
      hooks: {
        transformRequest: async (req) => {
          // Always POST to the GraphQL endpoint
          return new Request(this.endpoint, {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              ...headers,
              ...Object.fromEntries(req.headers),
            },
            body: req.body,
            signal: req.signal,
          })
        },
        transformResponse: async (res) => {
          const data = await res.json()
          if (data.errors && data.errors.length > 0) {
            throw new GraphQLError(data.errors, data.data)
          }
          // Return a new response with just the data
          return new Response(JSON.stringify(data.data), {
            status: res.status,
            headers: res.headers,
          })
        },
      },
    })
  }

  async query<T = any>(
    query: string,
    variables?: Record<string, any>
  ): Promise<T> {
    const response = await this.client('', {
      body: JSON.stringify({ query, variables }),
    })
    return response.json()
  }

  async mutate<T = any>(
    mutation: string,
    variables?: Record<string, any>
  ): Promise<T> {
    return this.query<T>(mutation, variables)
  }
}

class GraphQLError extends Error {
  constructor(
    public errors: any[],
    public data: any
  ) {
    super(`GraphQL Error: ${errors.map((e) => e.message).join(', ')}`)
  }
}

// Usage
const gql = new GraphQLClient('https://api.example.com/graphql', {
  Authorization: 'Bearer ' + token,
})

const user = await gql.query(
  `
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
      email
    }
  }
`,
  { id: '123' }
)

File Upload Client

import { createClient, type FFetch } from '@fetchkit/ffetch'

class FileUploadClient {
  private client: FFetch

  constructor() {
    this.client = createClient({
      timeout: 300000, // 5 minutes for uploads
      retries: 1, // Limited retries for file uploads
    })
  }

  async uploadFile(file: File, url: string): Promise<any> {
    const formData = new FormData()
    formData.append('file', file)

    const response = await this.client(url, {
      method: 'POST',
      body: formData,
      hooks: {
        before: async (req) => {
          console.log(
            `Uploading ${file.name} (${(file.size / 1024 / 1024).toFixed(2)} MB)`
          )
        },
        after: async (req, res) => {
          console.log(`Upload complete: ${res.status}`)
        },
        onError: async (req, err) => {
          console.error(`Upload failed: ${err.message}`)
        },
      },
    })

    return response.json()
  }

  async uploadWithMetadata(
    file: File,
    url: string,
    metadata: Record<string, string>
  ): Promise<any> {
    const formData = new FormData()
    formData.append('file', file)

    // Add metadata fields
    Object.entries(metadata).forEach(([key, value]) => {
      formData.append(key, value)
    })

    const response = await this.client(url, {
      method: 'POST',
      body: formData,
    })

    return response.json()
  }

  async uploadMultiple(files: File[], url: string): Promise<any[]> {
    // Upload files in parallel with concurrency limit
    const concurrency = 3
    const results: any[] = []

    for (let i = 0; i < files.length; i += concurrency) {
      const batch = files.slice(i, i + concurrency)
      const batchPromises = batch.map((file) =>
        this.uploadFile(file, url).catch((err) => ({
          error: err.message,
          file: file.name,
        }))
      )
      const batchResults = await Promise.all(batchPromises)
      results.push(...batchResults)
    }

    return results
  }
}

// Usage
const uploader = new FileUploadClient()

// Single file upload
const fileInput = document.querySelector<HTMLInputElement>('#file-input')!
const file = fileInput.files![0]
const result = await uploader.uploadFile(file, '/api/upload')

// Multiple file upload with metadata
const files = Array.from(fileInput.files!)
const results = await uploader.uploadMultiple(files, '/api/upload')

No Timeout for Long Operations

For very large uploads, streaming operations, or long-running requests where you don't want any timeout:

import { createClient } from '@fetchkit/ffetch'

// Client with no timeout - useful for streaming or very large uploads
const streamingClient = createClient({
  timeout: 0, // Disables timeout entirely
  retries: 0, // Usually don't retry streaming operations
})

// Example: Stream large file upload
async function uploadLargeFile(file: File) {
  const response = await streamingClient('/api/upload/stream', {
    method: 'POST',
    body: file,
    headers: {
      'Content-Type': file.type,
      'Content-Length': file.size.toString(),
    },
  })

  if (!response.ok) {
    throw new Error(`Upload failed: ${response.status}`)
  }

  return response.json()
}

// Or override timeout per request
const client = createClient({ timeout: 5000 }) // Default 5s timeout

async function normalRequest() {
  return client('/api/quick') // Uses 5s timeout
}

async function longRequest() {
  return client('/api/long-process', {
    timeout: 0, // No timeout for this specific request
  })
}

Real-time Data Polling

import { createClient, AbortError, type FFetch } from '@fetchkit/ffetch'

class DataPoller {
  private client: FFetch
  private intervalId?: number
  private abortController?: AbortController

  constructor() {
    this.client = createClient({
      timeout: 5000,
      retries: 2,
    })
  }

  startPolling(
    url: string,
    interval: number,
    onData: (data: any) => void,
    onError?: (error: Error) => void
  ) {
    this.stopPolling()
    this.abortController = new AbortController()

    const poll = async () => {
      try {
        const response = await this.client(url, {
          signal: this.abortController?.signal,
        })
        const data = await response.json()
        onData(data)
      } catch (error) {
        if (error instanceof AbortError) {
          return // Polling was stopped
        }
        onError?.(error as Error)
      }
    }

    // Poll immediately, then on interval
    poll()
    this.intervalId = window.setInterval(poll, interval)
  }

  stopPolling() {
    if (this.intervalId) {
      clearInterval(this.intervalId)
      this.intervalId = undefined
    }
    if (this.abortController) {
      this.abortController.abort()
      this.abortController = undefined
    }
  }
}

// Usage
const poller = new DataPoller()
poller.startPolling(
  'https://api.example.com/status',
  5000, // Poll every 5 seconds
  (data) => console.log('Status update:', data),
  (error) => console.error('Polling error:', error)
)

// Stop polling when component unmounts or page unloads
window.addEventListener('beforeunload', () => poller.stopPolling())

Caching with TTL

In-flight Deduplication with Dedupe Map TTL

Use this when you want to dedupe concurrent identical requests and optionally evict stale in-flight dedupe keys:

import { createClient } from '@fetchkit/ffetch'
import { dedupePlugin } from '@fetchkit/ffetch/plugins/dedupe'

const client = createClient({
  plugins: [
    dedupePlugin({
      ttl: 30_000,
      sweepInterval: 5_000,
    }),
  ],
})

const p1 = client('https://api.example.com/profile')
const p2 = client('https://api.example.com/profile')

// Same in-flight request is shared
const [r1, r2] = await Promise.all([p1, p2])

Note: dedupe plugin ttl is not response caching TTL. It only evicts entries in the internal dedupe map.

import { createClient, type FFetch } from '@fetchkit/ffetch'

interface CacheEntry<T> {
  data: T
  timestamp: number
  ttl: number
}

class CachedApiClient {
  private client: FFetch
  private cache = new Map<string, CacheEntry<any>>()

  constructor() {
    this.client = createClient({
      timeout: 10000,
      retries: 2,
    })
  }

  async get<T>(url: string, ttl: number = 60000): Promise<T> {
    const cacheKey = url
    const cached = this.cache.get(cacheKey)

    // Check if cache is valid
    if (cached && Date.now() - cached.timestamp < cached.ttl) {
      console.log('Cache hit:', url)
      return cached.data
    }

    console.log('Cache miss:', url)
    const response = await this.client(url)
    const data = await response.json()

    // Store in cache
    this.cache.set(cacheKey, {
      data,
      timestamp: Date.now(),
      ttl,
    })

    return data
  }

  async post<T>(url: string, body: any, cacheTtl?: number): Promise<T> {
    const response = await this.client(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    })
    const data = await response.json()

    // Optionally cache POST responses
    if (cacheTtl) {
      this.cache.set(`${url}:${JSON.stringify(body)}`, {
        data,
        timestamp: Date.now(),
        ttl: cacheTtl,
      })
    }

    return data
  }

  invalidate(pattern?: string) {
    if (pattern) {
      const regex = new RegExp(pattern)
      for (const [key] of this.cache) {
        if (regex.test(key)) {
          this.cache.delete(key)
        }
      }
    } else {
      this.cache.clear()
    }
  }

  // Clean expired entries
  cleanup() {
    const now = Date.now()
    for (const [key, entry] of this.cache) {
      if (now - entry.timestamp >= entry.ttl) {
        this.cache.delete(key)
      }
    }
  }

  getCacheStats() {
    return {
      size: this.cache.size,
      keys: Array.from(this.cache.keys()),
    }
  }
}

// Usage with automatic cleanup
const cachedClient = new CachedApiClient()
setInterval(() => cachedClient.cleanup(), 60000)

// Examples
const config = await cachedClient.get('/api/config', 300000) // Cache for 5 minutes
const users = await cachedClient.get('/api/users', 60000) // Cache for 1 minute
const result = await cachedClient.post('/api/search', { query: 'test' }, 30000) // Cache search results

Error Handling Patterns

Graceful Degradation

import { createClient, type FFetch } from '@fetchkit/ffetch'
import { circuitPlugin } from '@fetchkit/ffetch/plugins/circuit'

class ResilientApiClient {
  private client: FFetch
  private fallbackData: Record<string, any> = {}

  constructor() {
    this.client = createClient({
      timeout: 5000,
      retries: 3,
      plugins: [circuitPlugin({ threshold: 5, reset: 30000 })],
    })
  }

  async getWithFallback<T>(url: string, fallback: T): Promise<T> {
    try {
      const response = await this.client(url)
      const data = await response.json()

      // Cache successful response as fallback for next time
      this.fallbackData[url] = data
      return data
    } catch (error) {
      console.warn(`API call failed, using fallback:`, error.message)

      // Use cached data if available
      if (this.fallbackData[url]) {
        return this.fallbackData[url]
      }

      // Use provided fallback
      return fallback
    }
  }
}

// Usage
const client = new ResilientApiClient()
const config = await client.getWithFallback('/api/config', {
  theme: 'light',
  features: ['basic'],
})

Retry with Different Strategies

const strategicClient = createClient({
  retries: 3,
  shouldRetry: ({ attempt, response, error }) => {
    // Don't retry client errors (4xx)
    if (response && response.status >= 400 && response.status < 500) {
      return false
    }

    // Don't retry after 3 attempts
    if (attempt > 3) {
      return false
    }

    // Don't retry user aborts
    if (error instanceof AbortError) {
      return false
    }

    return true
  },
  retryDelay: ({ attempt, response }) => {
    // Exponential backoff with jitter
    const baseDelay = Math.pow(2, attempt - 1) * 1000
    const jitter = Math.random() * 1000

    // Longer delay for rate limiting
    if (response?.status === 429) {
      const retryAfter = response.headers.get('retry-after')
      if (retryAfter) {
        return parseInt(retryAfter) * 1000
      }
      return baseDelay * 2
    }

    return baseDelay + jitter
  },
})