For deployment readiness, alerting, and incident response, use the canonical runbook in production-operations.md.
You can override any client option on a per-request basis by passing it in the init parameter:
const client = createClient({ timeout: 5000, retries: 2 })
// Override timeout and retries for this request only
await client('https://api.example.com/v1/users', {
timeout: 1000, // 1s timeout for this request
retries: 5, // up to 5 retries for this request
})
// Disable timeout entirely for long-running operations
await client('https://api.example.com/v1/long-process', {
timeout: 0, // No timeout - request can run indefinitely
})Note: Setting
timeout: 0disables the timeout entirely, allowing requests to run indefinitely. This is useful for streaming operations, large file uploads, or long-running processes.
await client('https://api.example.com/v1/data', {
retryDelay: ({ attempt }) => 100 * attempt, // linear backoff for this request
})
// Override hooks for a single request
await client('https://api.example.com/v1/metrics', {
hooks: {
before: (req) => console.log('Single request:', req.url),
},
})ffetch can wrap any fetch-compatible implementation using the fetchHandler option. This includes native fetch, node-fetch, undici, or framework-provided fetch (SvelteKit, Next.js, Nuxt, etc.), as well as polyfills and test runners. All advanced features (timeouts, retries, circuit breaker, hooks, pending requests) work identically regardless of the underlying fetch implementation, making ffetch highly flexible for SSR, edge, and custom environments.
Pending requests and abort logic work identically whether you use the default global fetch or a custom fetch implementation via fetchHandler. All requests are tracked, and you can abort them programmatically using the controller in each PendingRequest.
Every PendingRequest always has a controller property, even if you did not supply an AbortController. This allows you to abort any pending request programmatically, regardless of how it was created.
Signal combination (user, timeout, transformRequest) requires AbortSignal.any. If your environment does not support it, you must install a polyfill before using ffetch.
You can access and monitor all active requests through the pendingRequests property on the client instance:
const client = createClient()
// Start some requests
const promise1 = client('https://api.example.com/users')
const promise2 = client('https://api.example.com/posts')
// Monitor active requests
console.log(`${client.pendingRequests.length} requests in flight`)
client.pendingRequests.forEach((pending) => {
console.log({
url: pending.request.url,
method: pending.request.method,
isAborted: pending.controller.signal.aborted,
})
})ffetch automatically combines user, timeout, and transformRequest signals. If your environment does not support AbortSignal.any, ffetch uses an internal controller to ensure aborts are handled consistently.
// Example: Abort all pending requests on page unload
window.addEventListener('beforeunload', () => {
client.abortAll() // Instantly aborts all active requests
})// Example: Wait for all pending requests to complete
await Promise.allSettled(
client.pendingRequests.map((pending) => pending.promise)
)// Example: Real-time monitoring
setInterval(() => {
console.log(
'Active requests:',
client.pendingRequests.map((p) => ({
url: p.request.url,
method: p.request.method,
aborted: p.controller.signal.aborted,
}))
)
}, 1000)- Each pending request object contains:
promise- The Promise for the requestrequest- The Request object with URL, headers, method, etc.controller- The AbortController for the request (use.abort()to cancel)- Requests are automatically added when they start and removed when they complete (success or failure)
- Each client instance maintains its own separate
pendingRequestsarray - You can abort all requests at once using
client.abortAll()
If a request is aborted while waiting between retries, ffetch stops waiting immediately and does not start another retry attempt.
You can provide a function for retryDelay that receives a context object:
const client = createClient({
retryDelay: ({ attempt, request, response, error }) => {
// attempt: number (starts at 1 for first retry decision)
// request: Request
// response: Response | undefined
// error: unknown
// Exponential backoff with cap
return Math.min(2 ** attempt * 1000, 30000)
// Custom logic based on response
if (response?.status === 429) {
// Longer delay for rate limiting
return 5000
}
return 1000
},
})const client = createClient({
shouldRetry: ({ attempt, request, response, error }) => {
// Don't retry more than 3 times
if (attempt > 3) return false
// Retry only on 503 Service Unavailable
return response?.status === 503
// Custom logic based on error type
if (error instanceof NetworkError) return true
if (error instanceof TimeoutError) return attempt <= 2
return false
},
})By default, if the server responds with a Retry-After header (either in seconds or as a date), ffetch will honor it and use it as the delay before the next retry.
Important distinction:
shouldRetrydecides whether a retry should happen.retryDelaydecides when that retry runs.
Retry-After is applied in delay timing (via retry delay behavior), not as a separate retry decision trigger.
If you override retry behavior, make sure your custom logic still handles Retry-After semantics if you want that behavior.
// The server sends: Retry-After: 30
// ffetch will wait 30 seconds before retrying
// The server sends: Retry-After: Wed, 21 Oct 2015 07:28:00 GMT
// ffetch will wait until that date/time before retryingThe circuit breaker pattern protects your service from repeated failures by temporarily blocking requests after a threshold of consecutive errors. This helps prevent cascading failures and allows your system to recover gracefully.
You can inspect the circuit breaker state at runtime using the client.circuitOpen property:
if (client.circuitOpen) {
console.warn('Circuit is open! Requests will fail fast.')
// Optionally log, alert, or trigger fallback logic
}This is useful for:
- Monitoring service health
- Logging or alerting when the circuit opens/closes
- Implementing custom fallback or degraded mode logic
- Integrating with dashboards or metrics
Note:
client.circuitOpenis provided bycircuitPlugin. If that plugin is not installed, this extension is not available on the client.
- A failure is counted when the response status is 5xx or 429, or when a network error is thrown. Other 4xx responses (400, 401, 403, 404, etc.) are not counted as failures — they reset the consecutive failure count as if the request succeeded.
- When the number of consecutive failures reaches the
threshold, the circuit "opens" and all further requests fail fast with aCircuitOpenError - After the
resetperiod (in milliseconds), the circuit "closes" and requests are allowed again - If a request succeeds or returns a non-failure 4xx, the failure count resets
- If
onCircuitOpenis configured, it runs both when the circuit opens and when requests are blocked while it is already open
import { createClient } from '@fetchkit/ffetch'
import { circuitPlugin } from '@fetchkit/ffetch/plugins/circuit'
const client = createClient({
retries: 0, // let circuit breaker handle failures
plugins: [
circuitPlugin({
threshold: 5, // Open after 5 consecutive failures
reset: 30_000, // Close after 30 seconds
onCircuitOpen: ({ request, reason }) => {
console.warn('Circuit open:', request.url, reason.type)
},
onCircuitClose: ({ request, response }) => {
console.info('Circuit closed:', request.url, response.status)
},
}),
],
})threshold: (number) — How many consecutive failures will "trip" (open) the circuit. Example:5means after 5 failures, the circuit opens.reset: (number, ms) — How long (in milliseconds) to wait before closing the circuit and allowing requests again. Example:30_000is 30 seconds.onCircuitOpen: (function) — Receives{ request, reason }.reason.typeis'threshold-reached'or'already-open'.onCircuitClose: (function) — Receives{ request, response }when the circuit closes on a successful recovery probe.
// Different thresholds for different endpoints
import { createClient } from '@fetchkit/ffetch'
import { circuitPlugin } from '@fetchkit/ffetch/plugins/circuit'
const apiClient = createClient({
plugins: [circuitPlugin({ threshold: 10, reset: 60_000 })], // More tolerant for API
})
const healthClient = createClient({
plugins: [circuitPlugin({ threshold: 3, reset: 10_000 })], // Less tolerant for health checks
})ffetch throws custom error classes for robust error handling. All custom errors have a .cause property:
- If the error is mapped from a native error (e.g., a DOMException or TypeError from fetch),
.causewill reference the original error - If the error is user-initiated (e.g., user aborts a request),
.causewill beundefined
TimeoutError: The request timed outAbortError: The request was aborted by the userCircuitOpenError: The circuit breaker is open and requests are blockedRetryLimitError: The retry limit was reached and the request failedNetworkError: A network error occurred (e.g., DNS failure, offline)
Important: ffetch follows the same behavior as native fetch() for HTTP status codes. HTTP errors (4xx, 5xx) do not throw exceptions - they return Response objects normally. You must check response.ok or response.status to handle HTTP errors.
import {
createClient,
TimeoutError,
AbortError,
CircuitOpenError,
RetryLimitError,
NetworkError,
} from '@fetchkit/ffetch'
const client = createClient({ timeout: 1000, retries: 2 })
try {
const response = await client('https://example.com')
// Check for HTTP errors manually (like native fetch)
if (!response.ok) {
console.log('HTTP error:', response.status, response.statusText)
// Handle HTTP errors based on status code
if (response.status === 404) {
// Handle not found
} else if (response.status >= 500) {
// Handle server errors
}
return
}
// Handle successful response
const data = await response.json()
} catch (err) {
if (err instanceof TimeoutError) {
console.log('Request timed out')
// Maybe show a user-friendly timeout message
} else if (err instanceof AbortError) {
console.log('Request was cancelled')
// User cancelled, no action needed
} else if (err instanceof CircuitOpenError) {
console.log('Service is currently unavailable')
// Show maintenance message
} else if (err instanceof RetryLimitError) {
console.log('Request failed after retries')
// Log the underlying error: err.cause
} else if (err instanceof NetworkError) {
console.log('Network connectivity issue')
// Show offline message
} else {
console.log('Unknown error:', err)
// Fallback error handling
}
}// All errors provide context for debugging
catch (err) {
if (err instanceof RetryLimitError) {
console.log('Request failed after all retries')
console.log('Last error was:', err.cause)
}
if (err instanceof TimeoutError) {
console.log('Request timed out')
console.log('Original cause:', err.cause)
}
// For HTTP errors, check the response object:
const response = await client('https://example.com')
if (!response.ok) {
console.log('HTTP error:', response.status, response.statusText)
const errorBody = await response.text()
console.log('Error response body:', errorBody)
}
}