Skip to content

Advanced Usage

In-depth explanations and full examples for createRestClient() options that go beyond a one-line description — custom transports, resilience against a flaky/overloaded backend, distributed state across server instances, and observability.

HTTP Adapter (custom fetch / edge environments)

Replace the built-in axios client with any HTTP implementation via the adapter option:

js
const fetchAdapter = {
  async request(config) {
    const url = `${config.baseURL ?? ''}${config.url ?? ''}`
    const res = await fetch(url, {
      method: config.method ?? 'GET',
      body: config.data ? JSON.stringify(config.data) : undefined,
      headers: { 'Content-Type': 'application/json', ...config.headers },
      signal: config.signal,
    })
    const data = await res.json()
    return {
      data,
      status: res.status,
      statusText: res.statusText,
      headers: Object.fromEntries(res.headers.entries()),
    }
  },
}

const client = createRestClient({
  baseURL: 'https://api.example.com',
  adapter: fetchAdapter,
  // Auth, interceptors, sanitizeHeaders, metrics still work on top of the adapter
  auth: { getToken: async () => token },
})
ts
type HttpAdapter = {
  request<T = unknown>(config: RestRequestConfig & { baseURL?: string }): Promise<ApiResponse<T>>
}

When adapter is set, createRestClient() never calls axios.create() — the built-in axios instance simply isn't constructed, so adapter-only usage (e.g. in Cloudflare Workers / Deno) doesn't pay for it.

Distributed rate limiting (RateLimiterStore)

By default, rateLimit is enforced in-memory, scoped to that one client instance — fine for a browser SPA, but each server process enforces its own limit in a multi-instance deployment, so N instances effectively allow N× the configured limit. Pass rateLimit.store to share the limit across instances (e.g. via Redis):

ts
import { createRestClient, type RateLimiterStore } from 'rest-pipeline-js'

const redisRateLimiterStore: RateLimiterStore = {
  async incrementWindow(key, intervalMs) {
    const count = await redis.incr(key)
    if (count === 1) await redis.pexpire(key, intervalMs)
    return count
  },
  async acquireConcurrencySlot(key, maxConcurrent, leaseMs) {
    // Needs an atomic increment-if-below-cap (typically a small Lua script) —
    // see examples/redis-rate-limiter-store.ts for a full sketch.
    // ...
  },
}

const client = createRestClient({
  baseURL: 'https://api.example.com',
  rateLimit: {
    maxRequestsPerInterval: 100,
    intervalMs: 60_000,
    store: redisRateLimiterStore,
    key: 'api-example-com', // shared bucket name across every instance
  },
})

Notes:

  • Without an explicit key, every RateLimiter instance gets its own random key — a store only has a sharing effect once multiple limiters (across processes) use the same key.
  • incrementWindow is a fixed-window counter — it has the standard edge-of-window burst characteristic of any fixed-window rate limiter (as opposed to a sliding log). This is a deliberate simplicity trade-off; implement a sliding-window store yourself if you need stricter bounds.
  • acquireConcurrencySlot (maxConcurrent) cannot be made exactly correct across processes without a central lock service — treat it as an approximate cap, the way most distributed semaphores work in practice. leaseMs bounds how long a slot is held if its holder crashes without releasing.

See examples/redis-rate-limiter-store.ts for the full annotated version.

Proactive throttling from rate-limit response headers

By default, the rate limiter only reacts after a request fails (429 + Retry-After, or the circuit breaker tripping). Many APIs also tell you how close you are to the limit on every response — X-RateLimit-Remaining, the IETF-draft RateLimit-Remaining, or a vendor-specific header. rateLimit.onRateLimitHeaders lets you read those and throttle proactively, before you actually get a 429:

ts
const client = createRestClient({
  baseURL: 'https://api.example.com',
  rateLimit: {
    onRateLimitHeaders: (headers, control) => {
      const remaining = Number(headers['x-ratelimit-remaining'])
      const resetSec = Number(headers['x-ratelimit-reset'])
      if (remaining === 0 && Number.isFinite(resetSec)) {
        control.throttleFor(resetSec * 1000)
      }
    },
  },
})

Notes:

  • The callback runs after every response — success and error alike (a 429 response usually carries the same headers as a normal one). The library doesn't parse any particular header format itself; there's no single standard, so you read whatever your backend sends.
  • control.throttleFor(ms) delays the next acquire() call(s) by at least ms — it composes with maxConcurrent/maxRequestsPerInterval rather than replacing them, and also applies when a distributed rateLimit.store is configured (the throttle wait happens locally, before delegating to the store).
  • A later, shorter throttleFor() call doesn't shorten an already-scheduled longer wait — the maximum wins.

Idempotency keys

Send an Idempotency-Key header on mutating requests (POST/PUT/PATCH/DELETE) so a backend that supports idempotency keys (Stripe, PayPal, and plenty of in-house APIs) can safely dedupe retried requests instead of double-applying them. The library only sends the header — deduplication is the backend's job.

js
// Manual: generate the key once per logical operation, reuse across attempts
const idempotencyKey = crypto.randomUUID()
await client.post('/orders', cart, { idempotencyKey })

// Custom header name
const client = createRestClient({ idempotencyHeaderName: 'X-Idempotency-Key' })

RequestExecutor (the class that actually implements retry; createRestClient()'s own client.post()/etc. don't retry on their own) can generate the key for you automatically via autoIdempotencyKey:

js
import { RequestExecutor } from 'rest-pipeline-js'

const executor = new RequestExecutor({
  baseURL: 'https://api.example.com',
  autoIdempotencyKey: true, // generates one key per logical request, reused across every retry attempt
  retry: { attempts: 2, delayMs: 300, backoffMultiplier: 2 },
})

await executor.execute('/orders', { method: 'POST', data: { items: ['sku-1'] } })

autoIdempotencyKey only affects mutating methods (POST/PUT/PATCH/DELETE) and only generates a key if the caller didn't already provide one via idempotencyKey. See examples/idempotent-mutations.ts.

Offline queue

Queue mutating requests (POST/PUT/PATCH/DELETE by default) made while offline instead of failing them immediately, and replay them — in order, using the same Idempotency-Key on every replay attempt — once connectivity returns. Configured via offlineQueue:

js
import { createRestClient, OfflineQueuedError } from 'rest-pipeline-js'

const client = createRestClient({
  baseURL: 'https://api.example.com',
  offlineQueue: {
    enabled: true,
    // Reuses PipelineStateAdapter (see PipelineConfig.options.persistAdapter) —
    // any save/load pair works, e.g. localStorage in a browser.
    persistAdapter: {
      save: (queue) => localStorage.setItem('offline-queue', JSON.stringify(queue)),
      load: () => JSON.parse(localStorage.getItem('offline-queue') ?? 'null'),
    },
    onFlushSuccess: (request, response) => console.log('synced', request.url, response.data),
    onFlushError: (request, error) => console.error('failed permanently', request.url, error),
  },
})

try {
  await client.post('/orders', cart)
} catch (err) {
  if (err instanceof OfflineQueuedError) {
    // Queued, not a network failure — err.queueId correlates with the
    // eventual onFlushSuccess/onFlushError callback.
    console.log('Order queued, will sync automatically:', err.queueId)
  } else {
    throw err
  }
}
  • shouldQueue — which requests get queued; default is the mutating methods (POST/PUT/PATCH/DELETE). GET is never queued by default (a stale read isn't useful to "replay" later).
  • isOnline/onOnlineChange — default to navigator.onLine and the browser's "online" event. Provide your own for Node/React Native (e.g. React Native's NetInfo) — without onOnlineChange outside a browser, nothing triggers an automatic flush; call client.flushQueue() yourself when you know connectivity is back.
  • client.getQueuedRequests() — current queue contents, e.g. for a "N actions pending sync" badge.
  • client.flushQueue() — manually attempt to send everything queued (also happens automatically on reconnect).
  • Each queued request gets an Idempotency-Key (reused on every replay attempt, generated once if the caller didn't already set one) — the same mechanism as Idempotency keys above, so a backend that honors it won't double-apply a mutation that actually went through right before connectivity dropped.
  • flush() attempts each queued request once per call, not a backoff loop — a request that fails with a genuine HTTP error (has a status) is removed from the queue and reported via onFlushError; one with no status at all (indistinguishable from "still offline") is left queued and retried on the next flush. For per-attempt retry/backoff, that's what RequestExecutor's retry/jitterStrategy are for — a queue flush is a coarser retry cycle triggered by reconnect events, not a tight retry loop against a possibly still-recovering backend.

See examples/offline-queue.ts for the full annotated version.

Request tracing

Two independent features, both under tracing:

tracing.generateTraceparent adds a W3C Trace Context traceparent header to every request (skipped if the request already sets one explicitly), so any backend/APM that understands trace context can correlate the call with the rest of a distributed trace:

js
const client = createRestClient({
  baseURL: 'https://api.example.com',
  tracing: { generateTraceparent: true },
})

Pass traceId on a request to correlate multiple calls under one trace instead of a fresh random one each time — a pipeline's runId (UUID) with its dashes stripped is exactly the 32 hex characters the format needs:

js
await client.get('/users/1', { traceId: orchestrator.getRunId().replace(/-/g, '') })

tracing.provider wraps every request in a real span in your tracing system. Its shape (TracingProvider/TracingSpan) is deliberately a subset of OpenTelemetry's Span API (duck-typed — this package doesn't depend on @opentelemetry/api), so a real OTel SDK plugs in as a thin adapter:

ts
import { trace } from '@opentelemetry/api'
import { createRestClient, type TracingProvider } from 'rest-pipeline-js'

const tracer = trace.getTracer('my-app')
const otelProvider: TracingProvider = {
  startSpan: (name, attributes) => tracer.startSpan(name, { attributes }),
}

const client = createRestClient({
  baseURL: 'https://api.example.com',
  tracing: { generateTraceparent: true, provider: otelProvider },
})

startSpan(name, attributes) is called before each request; span.end() after; span.setStatus()/span.recordException() on error (both optional on TracingSpan — a minimal provider only needs end()). See examples/opentelemetry-tracing.ts for the full annotated version, including a dependency-free console-logging provider.

Auth Provider

Configured via auth. Automatically inject an Authorization: Bearer <token> header before every request. On a 401 response, onUnauthorized is called (e.g. to refresh the token) and the request is retried once — preventing infinite loops.

js
const client = createRestClient({
  baseURL: 'https://api.example.com',
  auth: {
    getToken: async () => {
      return localStorage.getItem('access_token') ?? ''
    },
    onUnauthorized: async () => {
      const newToken = await refreshAccessToken()
      localStorage.setItem('access_token', newToken)
    },
  },
})

// Authorization: Bearer <token> is added automatically to every request
const res = await client.get('/profile')

Caching the token

If getToken() is expensive (e.g. it talks to a secure storage or refresh endpoint), set tokenTtlMs to reuse the result across requests instead of calling getToken() before every single one. The cache is invalidated automatically on a 401, so the next request always re-fetches a fresh token before retrying:

js
const client = createRestClient({
  baseURL: 'https://api.example.com',
  auth: {
    getToken: async () => requestTokenFromSecureEnclave(), // expensive
    onUnauthorized: async () => refreshAccessToken(),
    tokenTtlMs: 5 * 60_000, // reuse for up to 5 minutes
  },
})

Log Sanitization

Mask sensitive headers in metrics callbacks (onRequestStart / onRequestEnd) so they never appear in logs. Controlled via sanitizeHeaders/sensitiveHeaders.

js
import { createRestClient, DEFAULT_SENSITIVE_HEADERS } from 'rest-pipeline-js'

// DEFAULT_SENSITIVE_HEADERS includes: authorization, x-api-key, x-auth-token,
// cookie, set-cookie, proxy-authorization

const client = createRestClient({
  baseURL: 'https://api.example.com',
  // sanitizeHeaders defaults to true — masking is on unless you opt out below.
  sensitiveHeaders: ['x-internal-secret'], // extend the default list
  metrics: {
    onRequestStart: (info) => {
      // info.requestHeaders — sensitive values replaced with "REDACTED"
      console.log(info.requestHeaders)
    },
  },
})

// To see raw headers (e.g. local debugging only), opt out explicitly:
// createRestClient({ ..., sanitizeHeaders: false });

Use sanitizeHeadersMap directly:

js
import { sanitizeHeadersMap } from 'rest-pipeline-js'

const safe = sanitizeHeadersMap({ authorization: 'Bearer abc', 'content-type': 'application/json' }, [
  'x-custom-secret',
])
// { authorization: "REDACTED", "content-type": "application/json" }

Circuit breaker

Protect a failing backend (and your own app) from piling up retries/timeouts. Configured via circuitBreaker: after failureThreshold consecutive failures, the client stops calling the network entirely for openMs and rejects requests immediately with a CircuitOpenError (code: "CIRCUIT_OPEN"). After openMs, it lets a probe request through (half-open); success closes the circuit again, failure re-opens it.

js
import { createRestClient, CircuitOpenError } from 'rest-pipeline-js'

const client = createRestClient({
  baseURL: 'https://api.example.com',
  circuitBreaker: {
    failureThreshold: 5, // open after 5 consecutive failures
    openMs: 30_000, // stay open for 30s before probing again
    successThreshold: 2, // need 2 successful probes to fully close
    isFailure: (error) => error.status === undefined || error.status >= 500, // ignore 4xx
  },
})

try {
  await client.get('/flaky-endpoint')
} catch (err) {
  if (err instanceof CircuitOpenError) {
    // rejected locally — no network call was made
  }
}

await client.getCircuitBreakerState() // "closed" | "open" | "half-open" — async
  • Works on top of retry, cache, rate limiting, auth, and custom adapters — it sits around the actual network call, same as those features.
  • Each retry attempt (from RequestExecutor/request.retry) counts as its own pass through the breaker, so a flaky endpoint with retries enabled opens the circuit faster, not slower.
  • Cancelled/aborted requests are never counted as failures.
  • Not set by default — without circuitBreaker, behavior is unchanged.
  • getCircuitBreakerState() (and every CircuitBreaker method) is async — it resolves synchronously (no real async work) unless circuitBreaker.store is set (see below).

Distributed circuit breaker (CircuitBreakerStore)

By default, circuit breaker state lives in-memory, scoped to that one client instance — each server process needs its own failureThreshold consecutive failures before it opens, so a struggling backend in a multi-instance deployment absorbs N× as many failures as configured before anything trips. Pass circuitBreaker.store to share open/closed/half-open state across instances:

ts
import { createRestClient, type CircuitBreakerStore } from 'rest-pipeline-js'

const redisCircuitBreakerStore: CircuitBreakerStore = {
  async get(key) {
    const raw = await redis.get(key)
    return raw ? JSON.parse(raw) : null
  },
  async set(key, state, ttlMs) {
    await redis.set(key, JSON.stringify(state), 'PX', ttlMs)
  },
  // Optional: atomic increment, avoids a get+compute+set race between
  // concurrent requests on different instances.
  async incrementCounter(key, field, ttlMs) {
    const n = await redis.incr(`${key}:${field}`)
    if (n === 1) await redis.pexpire(`${key}:${field}`, ttlMs)
    return n
  },
}

const client = createRestClient({
  baseURL: 'https://api.example.com',
  circuitBreaker: {
    failureThreshold: 5,
    openMs: 30_000,
    store: redisCircuitBreakerStore,
    key: 'api-example-com', // shared bucket name across every instance
  },
})

As with rateLimit.key, an explicit key is what makes multiple CircuitBreaker instances (across processes) actually share state — without it, each gets its own random key. Without incrementCounter, the breaker falls back to get-compute-set, which can under-count failures under heavy concurrent load across instances but remains fail-safe. See examples/redis-circuit-breaker-store.ts for the full annotated version.