Skip to content

Latest commit

 

History

History
377 lines (308 loc) · 10.1 KB

File metadata and controls

377 lines (308 loc) · 10.1 KB

Hooks & Request/Response Transformation

Hooks allow you to observe, log, or modify the request/response lifecycle. All hooks are optional and can be set globally or per-request.

Lifecycle Hooks

In this document, core hooks means the callbacks under createClient({ hooks: ... }):

  • before
  • after
  • onError
  • onRetry
  • onTimeout
  • onAbort
  • onComplete
  • transformRequest
  • transformResponse

These are separate from plugin lifecycle callbacks (setup, preRequest, wrapDispatch, onSuccess, onError, onFinally).

Available Hooks

  • before(req): Called before each request is sent
  • after(req, res): Called after a successful response
  • onError(req, err): Called when core request execution fails (before plugin post-processing)
  • onRetry(req, attempt, err, res): Called before each retry attempt
  • onTimeout(req): Called when a request times out
  • onAbort(req): Called when a request is aborted by the user
  • onComplete(req, res, err): Called when core request execution completes (last among core hooks)

Basic Hooks Example

const client = createClient({
  hooks: {
    before: async (req) => console.log('→', req.url),
    after: async (req, res) => console.log('←', res.status),
    onError: async (req, err) => console.error('Error:', err),
    onRetry: async (req, attempt, err) => console.log('Retrying', attempt),
    onTimeout: async (req) => console.warn('Timeout:', req.url),
    onAbort: async (req) => console.warn('Aborted:', req.url),
    onComplete: async (req, res, err) => console.log('Done:', req.url),
  },
})

Request/Response Transformation

Transform requests and responses to add authentication, modify headers, or process data.

Transform Request

const client = createClient({
  hooks: {
    transformRequest: async (req) => {
      // Add authentication header
      return new Request(req, {
        headers: {
          ...Object.fromEntries(req.headers),
          Authorization: `Bearer ${getToken()}`,
          'X-API-Key': 'secret-key',
        },
      })
    },
  },
})

Transform Response

const client = createClient({
  hooks: {
    transformResponse: async (res, req) => {
      // Automatically parse JSON and add metadata
      const data = await res.json()

      return new Response(
        JSON.stringify({
          data,
          meta: {
            url: req.url,
            timestamp: new Date().toISOString(),
            requestId: res.headers.get('x-request-id'),
          },
        }),
        {
          status: res.status,
          headers: res.headers,
        }
      )
    },
  },
})

Common Use Cases

1. Authentication

const apiClient = createClient({
  hooks: {
    transformRequest: async (req) => {
      const token = await getAuthToken()
      return new Request(req, {
        headers: {
          ...Object.fromEntries(req.headers),
          Authorization: `Bearer ${token}`,
        },
      })
    },

    after: async (req, res) => {
      // Handle 401 responses by refreshing token
      if (res.status === 401) {
        await refreshAuthToken()
        // Note: You might want to retry the request here
        // or handle this in your application logic
      }
    },

    onError: async (req, err) => {
      console.error('Request failed:', err.message)
    },
  },
})

2. Logging and Metrics

const logger = createLogger('api-client')
let requestCounter = 0

const client = createClient({
  hooks: {
    before: async (req) => {
      const requestId = ++requestCounter
      logger.info(`[${requestId}] → ${req.method} ${req.url}`)
      req.headers.set('X-Request-ID', requestId.toString())
    },

    after: async (req, res) => {
      const requestId = req.headers.get('X-Request-ID')
      const duration = res.headers.get('X-Response-Time') || 'unknown'
      logger.info(`[${requestId}] ← ${res.status} (${duration}ms)`)
    },

    onError: async (req, err) => {
      const requestId = req.headers.get('X-Request-ID')
      logger.error(`[${requestId}] ✗ ${err.constructor.name}: ${err.message}`)
    },

    onComplete: async (req, res, err) => {
      // Send metrics to monitoring system
      trackApiCall({
        url: req.url,
        method: req.method,
        status: res?.status,
        error: err?.constructor.name,
        timestamp: Date.now(),
      })
    },
  },
})

3. Request/Response Caching

const cache = new Map()

class CacheHitError extends Error {
  response: Response

  constructor(response: Response) {
    super('Cache hit')
    this.name = 'CacheHitError'
    this.response = response
  }
}

const client = createClient({
  hooks: {
    before: async (req) => {
      // Check cache for GET requests
      if (req.method === 'GET') {
        const cached = cache.get(req.url)
        if (cached && Date.now() - cached.timestamp < 60000) {
          // 1 minute cache
          throw new CacheHitError(cached.response)
        }
      }
    },

    after: async (req, res) => {
      // Cache successful GET responses
      if (req.method === 'GET' && res.ok) {
        cache.set(req.url, {
          response: res.clone(),
          timestamp: Date.now(),
        })
      }
    },
  },
})

4. Request Sanitization

const client = createClient({
  hooks: {
    transformRequest: async (req) => {
      // Remove sensitive headers in production
      if (process.env.NODE_ENV === 'production') {
        const headers = new Headers(req.headers)
        headers.delete('X-Debug-Token')
        headers.delete('X-Internal-User-ID')

        return new Request(req, { headers })
      }
      return req
    },

    transformResponse: async (res, req) => {
      // Remove sensitive data from responses
      if (res.headers.get('content-type')?.includes('application/json')) {
        const data = await res.json()

        // Remove internal fields
        delete data._internal
        delete data.debugInfo

        return new Response(JSON.stringify(data), {
          status: res.status,
          headers: res.headers,
        })
      }
      return res
    },
  },
})

5. Rate Limiting and Backpressure

const rateLimiter = new Map()

const client = createClient({
  hooks: {
    before: async (req) => {
      const host = new URL(req.url).host
      const now = Date.now()
      const requests = rateLimiter.get(host) || []

      // Remove old requests (older than 1 minute)
      const recentRequests = requests.filter((time) => now - time < 60000)

      // Check if we're over the limit (100 requests per minute)
      if (recentRequests.length >= 100) {
        throw new RateLimitError(`Rate limit exceeded for ${host}`)
      }

      // Add this request
      recentRequests.push(now)
      rateLimiter.set(host, recentRequests)
    },
  },
})

6. Request Retry with Custom Logic

const client = createClient({
  retries: 3,
  hooks: {
    onRetry: async (req, attempt, err, res) => {
      // `attempt` is zero-based in onRetry (0 = first retry)
      console.log(`Retry ${attempt + 1}/3 for ${req.url}`)

      // Add exponential backoff delay
      const delay = Math.min(1000 * Math.pow(2, attempt), 10000)
      console.log(`Waiting ${delay}ms before retry...`)
      await new Promise((resolve) => setTimeout(resolve, delay))

      // Modify request for retry (e.g., refresh auth token)
      if (res?.status === 401) {
        await refreshAuthToken()
      }
    },
  },
})

Circuit Breaker Plugin Callbacks

onCircuitOpen

Called when the circuit opens after consecutive failures, and also when a request is blocked while the circuit is already open. Receives a context with the request and open reason.

Signature: (ctx: { request: Request; reason: { type: 'threshold-reached' | 'already-open'; response?: Response; error?: unknown } }) => void | Promise<void>

onCircuitClose

Called when the circuit transitions to closed after a successful request. Receives a context with the request and the successful recovery response.

Signature: (ctx: { request: Request; response: Response }) => void | Promise<void>

Example

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

const client = createClient({
  plugins: [
    circuitPlugin({
      threshold: 2,
      reset: 1000,
      onCircuitOpen: ({ request, reason }) =>
        console.warn('Circuit opened due to:', request.url, reason.type),
      onCircuitClose: ({ request, response }) =>
        console.info('Circuit closed after:', request.url, response.status),
    }),
  ],
})

Hook Execution Order

When a request is made, hooks execute in this order:

  1. transformRequest - Modify the outgoing request
  2. before - Called just before sending
  3. Request is sent
  4. transformResponse - Modify the incoming response (if successful)
  5. after - Called after successful response
  6. onComplete - Last among core hooks

If an error occurs or retry is needed:

  1. onError - Called on core execution errors
  2. onRetry - Called before retry attempts
  3. onTimeout - Called on timeout errors
  4. onAbort - Called on abort errors
  5. Plugin onCircuitOpen callback - Called when circuit opens, and on blocked requests while already open
  6. Plugin onCircuitClose callback - Called when circuit breaker transitions to closed
  7. onComplete - Last among core hooks

After core hooks run, plugin lifecycle callbacks may still run (for example plugin onSuccess, onError, and onFinally). If a plugin throws after core completion, core onError is not re-fired and core onComplete may already have run with success arguments.

Per-Request Hooks

You can override hooks for individual requests:

// Global client with basic logging
const client = createClient({
  hooks: {
    before: (req) => console.log('Request:', req.url),
  },
})

// Override hooks for a specific request
await client('https://api.example.com/special', {
  hooks: {
    before: (req) => console.log('Special request:', req.url),
    after: (req, res) => console.log('Special response:', res.status),
  },
})