Skip to content

Latest commit

 

History

History
334 lines (252 loc) · 13.7 KB

plugins.md

File metadata and controls

334 lines (252 loc) · 13.7 KB

Plugin Architecture

From version 5 ffetch uses a plugin pipeline for optional behavior such as deduplication and circuit breaking.

Why Plugins

  • Keep the core client small.
  • Make optional features tree-shakeable.
  • Support first-party and third-party extensions.

Lifecycle Overview

Plugins run in a deterministic pipeline with two phases:

  1. Client creation phase
  • setup: runs once when createClient() is called. Use it to define client extensions.
  1. Request phase (runs for every request)
  • preRequest: runs before dispatch. Use it to validate, prepare, or fail fast.
  • beforeAttempt: runs immediately before each physical fetch attempt (initial, retry, and hedged). Use it to stamp per-attempt metadata such as trace spans. Throwing from it refuses the attempt: the request is not sent, and the error reaches the caller exactly as the hook raised it.
  • wrapDispatch: wraps request execution (before / after around next(ctx)).
  • decoratePromise: runs when the request promise is created, before it is returned to the caller.
  • onSuccess / onError: runs when the request settles. An onError hook can read ctx.metadata.provenance to tell whether the error came from the request's own attempt or from local code.
  • onFinally: always runs after success or error - for every plugin once the request has entered the plugin pipeline, even when another hook fails.

Per-request Timeline

For one request, the flow is:

  1. Build request context.
  2. Run preRequest hooks.
  3. Run composed wrapDispatch chain. beforeAttempt fires before each physical fetch (initial attempt, retries, and hedges).
  4. Create the request promise and pass it through decoratePromise.
  5. Return the (possibly decorated) promise to the caller.
  6. Later, when it settles, run onSuccess or onError.
  7. Run onFinally.

A step that throws does not skip the callbacks that have already started: once a request has entered the plugin pipeline, onError and onFinally reach every plugin. Core onComplete reports the failure even when it happens earlier - a transformRequest or before hook that throws while the request is still being prepared, before the pipeline starts, reaches onComplete alone.

Attempts and Retries

What beforeAttempt sees is one attempt of a retry sequence the core runs:

  • Attempts are numbered from 1, and beforeAttempt(ctx, attempt) is given the number of the attempt about to be dispatched - the initial attempt is 1, the first retry is 2, and a hedge is a separate dispatch with its own numbering.
  • ctx.metadata.retry describes the attempt that finished most recently: attempt, its lastError or the lastResponse it produced (an attempt does the one or the other), and what the retry decision answered about it as shouldRetryResult - which is unset for the last attempt a request's budget allows, because that one is never offered for a retry. The attempt a request ends on is recorded as well, so onSuccess/onError/onFinally read the attempt that actually ran. configuredRetries and configuredDelay are what the request was configured with.
  • A plugin that dispatches the request again through next(ctx) runs its own sequence, so a retried or hedged dispatch keeps its own attempt numbers.

What Each Hook Is For

  • preRequest: prepare request context (auth, validation, early abort).
  • beforeAttempt: stamp per-attempt metadata (for example, trace spans) right before each physical fetch, or refuse the attempt by throwing.
  • wrapDispatch: control execution around the network call.
  • decoratePromise: improve caller ergonomics (for example, add .json()).
  • onSuccess / onError: record outcomes, metrics, and side effects. An onError hook that treats a failure as evidence about the dependency should check ctx.metadata.provenance first: the error type says what was raised, not who raised it.
  • onFinally: cleanup that must always happen, including a request that failed after the plugin pipeline started.

Plugin Order

Execution order is deterministic:

  • Plugins are sorted by order (ascending).
  • For equal order, registration order is preserved.

Order details per hook:

  • preRequest, onSuccess, onError, onFinally, decoratePromise: run in sorted order.
  • wrapDispatch: composed in reverse to create nested wrappers. In practice, lower order wraps outermost (before runs first, after runs last).

Built-in Feature Plugins

The sections below are grouped by learning priority (convenience first, resilience second), not by runtime execution order. For execution order semantics, see Plugin Order.

Category Plugins
Convenience requestShortcutsPlugin, responseShortcutsPlugin, downloadProgressPlugin
Resilience contextIdPlugin, dedupePlugin, circuitPlugin, bulkheadPlugin, hedgePlugin
import { createClient } from '@fetchkit/ffetch'
import { requestShortcutsPlugin } from '@fetchkit/ffetch/plugins/request-shortcuts'
import { responseShortcutsPlugin } from '@fetchkit/ffetch/plugins/response-shortcuts'
import { downloadProgressPlugin } from '@fetchkit/ffetch/plugins/download-progress'
import { contextIdPlugin } from '@fetchkit/ffetch/plugins/context-id'
import { dedupePlugin } from '@fetchkit/ffetch/plugins/dedupe'
import { circuitPlugin } from '@fetchkit/ffetch/plugins/circuit'
import { bulkheadPlugin } from '@fetchkit/ffetch/plugins/bulkhead'
import { hedgePlugin } from '@fetchkit/ffetch/plugins/hedge'

const client = createClient({
  plugins: [
    requestShortcutsPlugin(),
    responseShortcutsPlugin(),
    downloadProgressPlugin((progress) => {
      console.log(`${(progress.percent * 100).toFixed(1)}%`)
    }),
    contextIdPlugin(),
    dedupePlugin({ ttl: 30_000, sweepInterval: 5_000 }),
    circuitPlugin({ threshold: 5, reset: 30_000 }),
    bulkheadPlugin({ maxConcurrent: 10, maxQueue: 50 }),
    hedgePlugin({ delay: 50 }),
  ],
})

Convenience plugins

The request shortcuts plugin adds HTTP method shortcuts on the client instance:

const client = createClient({
  plugins: [requestShortcutsPlugin()],
})

const usersResponse = await client.get('https://example.com/users')
const createResponse = await client.post('https://example.com/users', {
  body: JSON.stringify({ name: 'Alice' }),
  headers: { 'content-type': 'application/json' },
})

The response shortcuts plugin adds parsing convenience methods on the returned request promise:

const client = createClient({
  plugins: [responseShortcutsPlugin()],
})

const data = await client('https://example.com/users').json()

The download progress plugin exposes chunk-level progress updates:

const client = createClient({
  plugins: [
    downloadProgressPlugin((progress) => {
      console.log(progress.transferredBytes, progress.percent)
    }),
  ],
})

Resilience plugins

The context ID plugin injects a stable request context identifier and keeps it consistent across retries and hedged attempts:

const client = createClient({
  plugins: [contextIdPlugin()],
})

See Context ID Plugin for configuration details.

The dedupe plugin collapses identical in-flight requests:

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

The circuit plugin fails fast after repeated failures:

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

The bulkhead plugin limits per-client concurrency and applies backpressure with a bounded queue:

const client = createClient({
  plugins: [bulkheadPlugin({ maxConcurrent: 10, maxQueue: 50 })],
})

const response = await client('https://example.com/data')

Warning: Bulkhead and retries can amplify queue pressure, because each retry attempt must reacquire a bulkhead slot. Keep retry counts conservative when using small bulkhead limits.

The hedge plugin races multiple concurrent attempts and cancels losers when the first acceptable response wins:

const client = createClient({
  plugins: [hedgePlugin({ delay: 50 })],
})

// Hedge reduces tail latency by racing attempts after a short delay
const response = await client('https://example.com/data')

Warning: Combining hedgePlugin with retries multiplies upstream traffic. Each retry attempt can itself spawn 1 + maxHedges requests. For example, retries: 2 with maxHedges: 1 can produce up to 6 requests for a single call. Prefer one or the other — hedging for tail latency, retries for transient failures — rather than combining both.

Built-in and custom plugins are configured the same way: pass plugin instances in the plugins array when calling createClient().

From Using to Building Plugins

Once you're comfortable composing built-in plugins, the sections below show how to author your own plugins and extensions.

Writing a Custom Plugin

Use the public ClientPlugin type.

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

type TimingExtension = {
  lastDurationMs: number
}

function timingPlugin(): ClientPlugin<TimingExtension> {
  let lastDurationMs = 0

  return {
    name: 'timing',
    order: 100,
    setup: ({ defineExtension }) => {
      defineExtension('lastDurationMs', {
        get: () => lastDurationMs,
      })
    },
    preRequest: (ctx) => {
      ctx.state.start = Date.now()
    },
    onFinally: (ctx) => {
      const start =
        typeof ctx.state.start === 'number' ? ctx.state.start : Date.now()
      lastDurationMs = Date.now() - start
    },
  }
}

const client = createClient({
  plugins: [timingPlugin()] as const,
})

await client('https://example.com/data')
console.log(client.lastDurationMs)

Plugin Context and Types

For advanced plugins, import public context types:

import type {
  ClientPlugin,
  PluginRequestContext,
  PluginDispatch,
  PluginSetupContext,
  PluginProvenance,
} from '@fetchkit/ffetch'

What you can access in request context:

  • ctx.request: current Request object.
  • ctx.init: request init/options.
  • ctx.state: per-request mutable plugin state.
  • ctx.metadata: signal and retry metadata.
  • ctx.metadata.provenance: who raised the error onError is being told about, as a PluginProvenance. It is 'attempt' for an error the request's own attempt raised, and 'hook' for local code - a plugin refusing the request, a retry policy that threw, a response hook that failed - including a request that has not reached an attempt yet.

Wrapping Dispatch

Use wrapDispatch when you need around-advice behavior (before/after dispatch in one place):

import type { ClientPlugin } from '@fetchkit/ffetch'

const tracingPlugin: ClientPlugin = {
  name: 'tracing',
  wrapDispatch: (next) => async (ctx) => {
    console.log('start', ctx.request.url)
    try {
      const response = await next(ctx)
      console.log('end', response.status)
      return response
    } catch (error) {
      console.log('error', error)
      throw error
    }
  },
}

Decorating the Returned Promise

Use decoratePromise to attach convenience behavior to the returned promise without replacing native Response behavior.

import type { ClientPlugin } from '@fetchkit/ffetch'

const jsonShortcutPlugin: ClientPlugin<
  Record<never, never>,
  { json: () => Promise<unknown> }
> = {
  name: 'json-shortcut',
  decoratePromise: (promise) => {
    Object.defineProperty(promise, 'json', {
      value: function json(this: Promise<Response>) {
        return this.then((response) => response.json())
      },
      enumerable: false,
      writable: false,
      configurable: false,
    })
    return promise as Promise<Response> & { json: () => Promise<unknown> }
  },
}

Best Practices

  • Keep plugins side-effect free outside controlled state.
  • Prefer per-request data in ctx.state instead of global mutable variables.
  • Use order only when needed; document ordering assumptions.
  • Avoid throwing from onFinally unless intentional. A throw fails an otherwise successful request; on a request that already failed, the original error is kept.
  • Use as const plugin tuples for best TypeScript extension inference.

App-Level Concerns

Plugins provide request-time mechanics, but some resilience concerns are best handled at application boundaries: For a deployment checklist, metrics baseline, and incident playbook, see production-operations.md.

  • Graceful circuit recovery: When a circuit closes, avoid sending all queued or waiting traffic at once. Recover gradually (for example, ramp request rate or release waiting callers in batches) to prevent thundering herd spikes.
  • Bulkhead boundaries: Decide bulkhead scope per dependency (service, host, or endpoint class). A single global bulkhead can cause unrelated traffic to compete for the same slots.
  • Retry and hedge budgets: Set retry/hedge limits based on upstream capacity. Multiplicative traffic (retries x hedges) can overwhelm dependencies even if each plugin is correct in isolation.
  • Caching strategy: Pair plugin behavior with explicit cache policy (TTL, stale-while-revalidate, cache keys). Good caching reduces load and tail latency, but stale or over-broad keys can hide failures or serve incorrect data.
  • Fallback behavior: Define app-level fallback responses or degraded modes when circuit/bulkhead limits are hit, instead of only surfacing raw errors to end users.
  • Observability and alerting: Emit metrics for circuit opens/closes, bulkhead queue depth, and rejection rates. These signals are critical for tuning thresholds and queue sizes.