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:
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 },
})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):
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, everyRateLimiterinstance gets its own random key — astoreonly has a sharing effect once multiple limiters (across processes) use the samekey. incrementWindowis 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.leaseMsbounds 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:
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 nextacquire()call(s) by at leastms— it composes withmaxConcurrent/maxRequestsPerIntervalrather than replacing them, and also applies when a distributedrateLimit.storeis 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.
// 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:
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:
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 tonavigator.onLineand the browser's"online"event. Provide your own for Node/React Native (e.g. React Native'sNetInfo) — withoutonOnlineChangeoutside a browser, nothing triggers an automatic flush; callclient.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 astatus) is removed from the queue and reported viaonFlushError; one with nostatusat all (indistinguishable from "still offline") is left queued and retried on the next flush. For per-attempt retry/backoff, that's whatRequestExecutor'sretry/jitterStrategyare 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:
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:
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:
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.
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:
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.
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:
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.
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 everyCircuitBreakermethod) isasync— it resolves synchronously (no real async work) unlesscircuitBreaker.storeis 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:
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.