Hooks allow you to observe, log, or modify the request/response lifecycle. All hooks are optional and can be set globally or per-request.
In this document, core hooks means the callbacks under createClient({ hooks: ... }):
beforeafteronErroronRetryonTimeoutonAbortonCompletetransformRequesttransformResponse
These are separate from plugin lifecycle callbacks (setup, preRequest, wrapDispatch, onSuccess, onError, onFinally).
before(req): Called before each request is sentafter(req, res): Called after a successful responseonError(req, err): Called when core request execution fails (before plugin post-processing)onRetry(req, attempt, err, res): Called before each retry attemptonTimeout(req): Called when a request times outonAbort(req): Called when a request is aborted by the useronComplete(req, res, err): Called when core request execution completes (last among core hooks)
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),
},
})Transform requests and responses to add authentication, modify headers, or process data.
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',
},
})
},
},
})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,
}
)
},
},
})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)
},
},
})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(),
})
},
},
})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(),
})
}
},
},
})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
},
},
})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)
},
},
})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()
}
},
},
})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>
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>
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),
}),
],
})When a request is made, hooks execute in this order:
transformRequest- Modify the outgoing requestbefore- Called just before sending- Request is sent
transformResponse- Modify the incoming response (if successful)after- Called after successful responseonComplete- Last among core hooks
If an error occurs or retry is needed:
onError- Called on core execution errorsonRetry- Called before retry attemptsonTimeout- Called on timeout errorsonAbort- Called on abort errors- Plugin
onCircuitOpencallback - Called when circuit opens, and on blocked requests while already open - Plugin
onCircuitClosecallback - Called when circuit breaker transitions to closed 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.
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),
},
})