@fetchkit/ffetch now exports named symbols only.
import { createClient } from '@fetchkit/ffetch'Feature plugins are exported from subpath entrypoints:
import {
dedupePlugin,
dedupeRequestHash,
} from '@fetchkit/ffetch/plugins/dedupe'
import { bulkheadPlugin } from '@fetchkit/ffetch/plugins/bulkhead'
import { circuitPlugin } from '@fetchkit/ffetch/plugins/circuit'
import { hedgePlugin } from '@fetchkit/ffetch/plugins/hedge'
import { requestShortcutsPlugin } from '@fetchkit/ffetch/plugins/request-shortcuts'
import { responseShortcutsPlugin } from '@fetchkit/ffetch/plugins/response-shortcuts'
import { downloadProgressPlugin } from '@fetchkit/ffetch/plugins/download-progress'Custom plugin authoring is documented in plugins.md.
Creates a new HTTP client instance.
import { createClient } from '@fetchkit/ffetch'
const client = createClient({
timeout: 5000,
retries: 2,
throwOnHttpError: true,
})| Option | Type | Default | Description |
|---|---|---|---|
timeout |
number (ms) |
5000 |
Whole-request timeout in milliseconds. Use 0 to disable timeout. |
retries |
number |
0 |
Maximum retry attempts. |
retryDelay |
number | (ctx: { attempt, request, response, error }) => number |
Exponential backoff + jitter | Delay between retries. |
shouldRetry |
(ctx: { attempt, request, response, error }) => boolean |
Retries on network errors, 5xx, 429 | Custom retry logic. |
throwOnHttpError |
boolean |
false |
If true, throws an HttpError for 4xx/5xx/429 after retries are exhausted. |
hooks |
{ before, after, onError, onRetry, onTimeout, onAbort, onComplete, transformRequest, transformResponse } |
{} |
Lifecycle hooks and transformers. |
fetchHandler |
(input: RequestInfo | URL, init?: RequestInit) => Promise<Response> |
global fetch |
Custom fetch-compatible implementation to wrap. |
plugins |
ClientPlugin[] |
[] |
Optional plugin list. Use this for dedupe, circuit breaker, and third-party features. |
For advanced retry behavior (including Retry-After handling and custom delay/decision strategies), see advanced.md -> Retry Strategies and Backoff.
import { createClient } from '@fetchkit/ffetch'
import { dedupePlugin } from '@fetchkit/ffetch/plugins/dedupe'
const client = createClient({
plugins: [
dedupePlugin({
hashFn: (params) => `${params.method}|${params.url}|${params.body}`,
ttl: 30_000,
sweepInterval: 5_000,
}),
],
})import { createClient } from '@fetchkit/ffetch'
import { circuitPlugin } from '@fetchkit/ffetch/plugins/circuit'
const client = createClient({
plugins: [
circuitPlugin({
threshold: 5,
reset: 30_000,
onCircuitOpen: ({ request, reason }) => {
console.warn('Circuit opened:', request.url, reason.type)
},
onCircuitClose: ({ request, response }) => {
console.info('Circuit closed:', request.url, response.status)
},
}),
],
})
if (client.circuitOpen) {
console.warn('Circuit breaker is open')
}Notes:
onCircuitOpenreceives{ request, reason }wherereason.typeis either'threshold-reached'or'already-open'.- When
reason.type === 'threshold-reached',reason.response(for HTTP failures) orreason.error(for thrown failures) is populated. onCircuitClosereceives{ request, response }for the successful recovery probe that closed the circuit.
import { createClient } from '@fetchkit/ffetch'
import { bulkheadPlugin } from '@fetchkit/ffetch/plugins/bulkhead'
const client = createClient({
plugins: [
bulkheadPlugin({
maxConcurrent: 10,
maxQueue: 50,
}),
],
})
// Active requests run immediately up to maxConcurrent.
// Additional requests queue (up to maxQueue), then reject with BulkheadFullError.
const data = await client('https://api.example.com/data')Options:
| Option | Type | Default | Description |
|---|---|---|---|
maxConcurrent |
number |
Required | Maximum in-flight requests allowed at once. |
maxQueue |
number |
Unlimited | Maximum queued requests waiting for a slot. |
onReject |
(req: Request) => void or Promise<void> |
Undefined | Callback when a request is rejected because the queue is full. |
order |
number |
5 |
Plugin execution order (lower runs outermost in wrapDispatch). |
Notes:
- Bulkhead isolates concurrency pressure per client instance.
- Queued requests that abort are removed from the queue and rejected with
AbortError. - Combining bulkhead with retries can increase queue pressure because each retry reacquires a slot.
- With defaults, bulkhead (
order: 5) wraps before dedupe (order: 10). If you want dedupe to collapse callers before bulkhead slot acquisition, set a higher bulkhead order.
import { createClient } from '@fetchkit/ffetch'
import { hedgePlugin } from '@fetchkit/ffetch/plugins/hedge'
const client = createClient({
plugins: [
hedgePlugin({
delay: 50, // 50ms before sending hedge attempt
maxHedges: 1, // send at most 1 additional attempt
shouldHedge: (req) => req.method === 'GET', // only hedge GET requests
}),
],
})
// Concurrent requests automatically hedge; fast response wins
const data = await client('https://api.example.com/data')Options:
| Option | Type | Default | Description |
|---|---|---|---|
delay |
number | (req: Request) => number |
Required | Delay (ms) before sending hedge attempt. |
maxHedges |
number |
1 |
Maximum number of hedge attempts. |
shouldHedge |
(req: Request) => boolean |
Safe methods (GET, HEAD, etc.) | Function to determine if a request should be hedged. |
onHedge |
(req: Request, attempt: number) => void or Promise<void> |
Undefined | Callback when a hedge attempt is sent. |
order |
number |
15 |
Plugin execution order. |
Notes:
- Hedge races multiple attempts and returns the first acceptable response (ok status, or 4xx except 429). If all attempts settle without a clear winner, the last remaining attempt wins regardless of status.
- 5xx and 429 responses are not winners; hedge will wait for other attempts.
- Loser attempts are cancelled (via
AbortController) to prevent wasted bandwidth. - Hedge and retries are alternative strategies; combining them multiplies traffic. Use retries or hedge, not both, unless you carefully quantify the cost.
- Hedge is ordered at
15(between dedupe at10and circuit at20). Dedupe collapses callers before hedge races them.
import { createClient } from '@fetchkit/ffetch'
import { requestShortcutsPlugin } from '@fetchkit/ffetch/plugins/request-shortcuts'
const client = createClient({
plugins: [requestShortcutsPlugin()],
})
const users = await client.get('https://api.example.com/users')
const created = await client.post('https://api.example.com/users', {
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'Alice' }),
})Notes:
- The plugin is opt-in; default
createClient()behavior is unchanged. - Shortcut methods are available on the client instance:
get,post,put,patch,delete,head,options. - Each shortcut is equivalent to
client(url, { ...init, method: 'METHOD' }).
import { createClient } from '@fetchkit/ffetch'
import { responseShortcutsPlugin } from '@fetchkit/ffetch/plugins/response-shortcuts'
const client = createClient({
plugins: [responseShortcutsPlugin()],
})
const data = await client('https://api.example.com/users').json<
Array<{ id: number; name: string }>
>()
const html = await client('https://example.com/page').text()Notes:
- The plugin is opt-in; default
createClient()behavior is unchanged. await client(url)still returns a nativeResponse.- Shortcut methods are available on the returned request promise:
json,text,blob,arrayBuffer,formData.
import { createClient } from '@fetchkit/ffetch'
import { downloadProgressPlugin } from '@fetchkit/ffetch/plugins/download-progress'
const client = createClient({
plugins: [
downloadProgressPlugin((progress, chunk) => {
console.log(
`${(progress.percent * 100).toFixed(1)}% — ${progress.transferredBytes} bytes`
)
}),
],
})
const response = await client('https://example.com/large-file.zip')
await response.arrayBuffer() // drain the streamThe onProgress callback receives:
progress.percent— fraction from0to1. Always0whenContent-Lengthis absent.progress.transferredBytes— cumulative bytes received so far.progress.totalBytes— value ofContent-Lengthheader, or0if absent.chunk— the rawUint8Arraychunk just received.
Notes:
- The plugin is opt-in; default
createClient()behavior is unchanged. - The response body is fully stream-passthrough — callers can still read
.json(),.text(),.blob(), or.arrayBuffer()as normal. - If the response has no body (e.g.
204 No Content),onProgressis never called and the original response is returned unchanged.
Use the public plugin types from the root package and register your plugins via plugins.
import { createClient, type ClientPlugin } from '@fetchkit/ffetch'
const headerPlugin: ClientPlugin = {
name: 'header-plugin',
preRequest: (ctx) => {
ctx.request = new Request(ctx.request, {
headers: {
...Object.fromEntries(ctx.request.headers),
'x-trace-id': crypto.randomUUID(),
},
})
},
}
const client = createClient({
plugins: [headerPlugin],
})See plugins.md for full lifecycle, ordering, extensions, and advanced patterns.
The following top-level options were removed:
dedupededupeHashFndedupeTTLdedupeSweepIntervalcircuit
Use plugin modules instead via plugins: [...].
Core options can still be overridden per request:
await client('https://example.com/data', {
timeout: 1000,
retries: 0,
throwOnHttpError: true,
})type FFetch = {
(input: RequestInfo | URL, init?: RequestInit): Promise<Response>
// Available when requestShortcutsPlugin() is installed
get?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
post?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
put?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
patch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
delete?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
head?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
options?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
pendingRequests: PendingRequest[]
abortAll: () => void
// Plugin extensions are composed into this type
}When the request shortcuts plugin is installed, the client instance is augmented with HTTP method shortcuts (for example client.get(url) and client.post(url, init)).
When the response shortcuts plugin is installed, the call return value is augmented with parsing shortcuts while preserving await client(url) as Response.
const client = createClient({
plugins: [responseShortcutsPlugin()] as const,
})
// Promise<Response> + shortcut methods
const data = await client('https://example.com/data').json<{ ok: boolean }>()
// Native behavior still works
const response = await client('https://example.com/data')| Option | Default Value / Logic |
|---|---|
timeout |
5000 ms |
retries |
0 |
retryDelay |
({ attempt }) => 2 ** attempt * 200 + Math.random() * 100 |
shouldRetry |
Retries on network errors, HTTP 5xx, or 429. Does not retry 4xx (except 429), abort, or timeout |
throwOnHttpError |
false |
hooks |
{} |
plugins |
[] |
- Signal combination (user, timeout, transformRequest) requires
AbortSignal.any. - The first retry decision uses
attempt = 1. - Plugin order is deterministic: first by
order, then by registration order.