REST Client
createRestClient
createRestClient(config: HttpConfig): RestClientCreates a REST client with advanced HTTP features.
Methods
| Method | Description |
|---|---|
get(url, config?) | GET request |
post(url, data?, config?) | POST request |
put(url, data?, config?) | PUT request |
patch(url, data?, config?) | PATCH request |
delete(url, config?) | DELETE request |
request(url, config?) | Generic request |
cancellableRequest(key, url, config?) | Request cancellable by key |
cancelRequest(key) | Cancel request by key |
clearCache() | Clear this client's entire response cache (async) |
invalidateCache(matcher) | Clear only cache entries whose URL matches matcher (substring, RegExp, or (info) => boolean); returns (Promise<number>) the number of entries removed |
getCircuitBreakerState() | Promise<"closed" | "open" | "half-open" | null> — null if circuitBreaker isn't configured. Resolves synchronously (no real async work) unless circuitBreaker.store is set |
getQueuedRequests() | Promise<QueuedRequest[]> — requests awaiting the next offline-queue flush (empty if offlineQueue isn't configured) |
flushQueue() | Manually attempt to send everything queued (also happens automatically on reconnect); no-op if offlineQueue isn't configured |
HttpConfig options
| Option | Description |
|---|---|
baseURL | Base URL for all requests |
timeout | Request timeout in ms |
headers | Default headers |
withCredentials | Include cookies |
retry.attempts | Number of retry attempts |
retry.delayMs | Base delay between retries in ms |
retry.backoffMultiplier | Exponential backoff multiplier |
retry.retriableStatus | HTTP status codes eligible for retry (e.g. [429, 500, 503]) |
retry.maxRetryAfterMs | Max wait from Retry-After header in ms (default: 60000) |
retry.jitterStrategy | Backoff jitter algorithm: "fixed" (default), "full", or "decorrelated" |
cache.enabled | Enable response caching for GET requests |
cache.ttlMs | Cache TTL in ms |
cache.strategy | "strict" (default) or "stale-while-revalidate" |
cache.staleMs | Extra time after ttlMs a stale response may still be served (SWR strategy) |
cache.store | Custom CacheStore backend (e.g. Redis) instead of the built-in in-memory TtlCache — see Custom cache backend |
rateLimit.maxConcurrent | Max simultaneous requests |
rateLimit.maxRequestsPerInterval | Max requests per time window |
rateLimit.intervalMs | Time window size in ms |
rateLimit.store | Custom RateLimiterStore backend (e.g. Redis) for a limit shared across server instances — see Distributed rate limiting |
rateLimit.key | Bucket name when using a shared rateLimit.store (default: random per-instance id — without an explicit key, a store has no sharing effect) |
rateLimit.leaseMs | Auto-expiry (ms) for a store-backed concurrency slot if its holder crashes without releasing (default: 30000) |
rateLimit.onRateLimitHeaders | Callback with the raw response headers of every request (success or error) — proactively throttle from X-RateLimit-*/similar headers instead of only reacting to 429. See Proactive throttling |
metrics.onRequestStart | Callback on request start |
metrics.onRequestEnd | Callback on request end (includes duration and bytes) |
auth.getToken | Async function returning a Bearer token (called before every request, unless auth.tokenTtlMs is set) |
auth.onUnauthorized | Optional async callback on 401 — refresh the token here; request is retried once |
auth.tokenTtlMs | Cache getToken()'s result for this many ms instead of calling it before every request; invalidated automatically on 401 |
sanitizeHeaders | Mask sensitive headers in metrics callbacks (default: true — secure by default) |
sensitiveHeaders | Additional headers to mask (extends DEFAULT_SENSITIVE_HEADERS) |
adapter | Custom HTTP adapter (e.g. native fetch) — replaces built-in axios |
circuitBreaker | See Circuit breaker — { failureThreshold, openMs, successThreshold?, isFailure?, store?, key? } |
tracing.generateTraceparent | Add a W3C traceparent header to every request (default: false) — see Request tracing |
tracing.provider | TracingProvider hook creating a span per request — see Request tracing |
idempotencyHeaderName | Header name used for RestRequestConfig.idempotencyKey (default: "Idempotency-Key") — see Idempotency keys |
autoIdempotencyKey | Have RequestExecutor auto-generate an idempotency key per logical request (default: false) — see Idempotency keys |
offlineQueue | Queue mutating requests made while offline and replay them once back online — { enabled, persistAdapter, isOnline?, onOnlineChange?, shouldQueue?, maxQueueSize?, onFlushSuccess?, onFlushError? }. See Offline queue |
Per-request cache override
const res = await client.get('/data', {
useCache: true,
cacheTtlMs: 30000,
cacheKey: 'my-custom-key',
})File uploads & progress
RestRequestConfig extends axios's own AxiosRequestConfig, so data can be a FormData/Blob/ArrayBuffer and onUploadProgress/onDownloadProgress are already typed and wired through — no extra configuration needed on the default (axios) transport:
const formData = new FormData()
formData.append('file', fileInput.files[0])
await client.post('/upload', formData, {
onUploadProgress: (event) => {
const percent = event.total ? Math.round((event.loaded / event.total) * 100) : 0
console.log(`Uploaded ${percent}%`)
},
})
await client.get('/large-report.csv', {
responseType: 'blob',
onDownloadProgress: (event) => console.log(event.loaded, 'bytes received'),
})If you use a custom adapter (see HTTP Adapter) instead of the built-in axios transport, onUploadProgress/onDownloadProgress are still passed through to your adapter's config object as-is, but the adapter is responsible for actually calling them — fetch has no native upload-progress event, so a fetch-based adapter needs a ReadableStream reader (or XMLHttpRequest) to implement it. See examples/file-upload.ts.
Targeted cache invalidation
clearCache() wipes the entire response cache. To invalidate only the entries affected by a mutation (e.g. after a POST/PUT/DELETE), use invalidateCache() instead — it accepts a substring, a RegExp, or a predicate over { method, url }, and resolves to how many entries were removed. Both methods are async (so a custom cache.store can be backed by a real network call):
await client.post('/users/1/orders', newOrder)
await client.invalidateCache('/users/1') // substring match on the cached URL
await client.invalidateCache(/^https:\/\/api\.example\.com\/users\/\d+$/)
await client.invalidateCache(({ method, url }) => method === 'GET' && url.includes('/orders'))Custom cache backend (CacheStore)
By default, cache.enabled: true caches responses in an in-memory TtlCache scoped to that one client instance — fine for a browser SPA, but each server process has its own cold cache in a multi-instance deployment. Pass cache.store to use any backend implementing CacheStore instead — Redis, for example, so cached responses are shared across every instance:
import { createRestClient, type CacheStore, type ApiResponse } from 'rest-pipeline-js'
const redisStore: CacheStore<ApiResponse<unknown>> = {
async get(key) {
const raw = await redis.get(key)
return raw ? JSON.parse(raw) : undefined
},
async set(key, value, ttlMs) {
await redis.set(key, JSON.stringify(value), 'PX', ttlMs)
},
async delete(key) {
await redis.del(key)
},
async clear() {
await redis.flushdb()
},
// getStale/deleteWhere are optional — without them, the
// 'stale-while-revalidate' strategy and invalidateCache() gracefully
// degrade (see CacheStore's JSDoc) instead of throwing.
}
const client = createRestClient({
baseURL: 'https://api.example.com',
cache: { enabled: true, ttlMs: 60_000, store: redisStore },
})See examples/redis-cache-store.ts for the full annotated version.
Full example
import { createRestClient } from 'rest-pipeline-js'
const client = createRestClient({
baseURL: 'https://api.example.com',
timeout: 5000,
retry: {
attempts: 2,
delayMs: 500,
backoffMultiplier: 2,
retriableStatus: [429, 500, 503],
},
cache: { enabled: true, ttlMs: 60000 },
rateLimit: { maxConcurrent: 3, maxRequestsPerInterval: 10, intervalMs: 1000 },
auth: {
getToken: async () => localStorage.getItem('token') ?? '',
onUnauthorized: async () => {
/* refresh token here */
},
},
sanitizeHeaders: true,
})
const res = await client.get('/users/1')
console.log(res.data)
// PATCH support
await client.patch('/users/1', { name: 'Alice' })
// Cancellable request
const req = client.cancellableRequest('my-key', '/search', {
params: { q: 'foo' },
})
// Cancel it any time:
client.cancelRequest('my-key')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 — see RequestExecutor; createRestClient()'s own client.post()/etc. don't retry on their own) can generate the key for you automatically:
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:
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:
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
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.
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" }RequestExecutor
Wrapper for REST requests with retry, timeout (via AbortController), Retry-After header support, and backoff.
import { RequestExecutor } from 'rest-pipeline-js'
const executor = new RequestExecutor({
baseURL: 'https://api.example.com',
retry: {
attempts: 3,
delayMs: 500,
backoffMultiplier: 2,
retriableStatus: [429, 500, 502, 503],
maxRetryAfterMs: 30000, // cap Retry-After at 30 s
},
})
// 5th arg: external AbortSignal (e.g. from orchestrator.abort())
const res = await executor.execute('/data', undefined, 3, 5000, signal)When the server returns a Retry-After header (numeric seconds or HTTP-date), that delay takes priority over the backoff formula. Values exceeding maxRetryAfterMs are clamped to the cap. Timeout is enforced via AbortController — the actual HTTP request is cancelled, not just the promise.
Jitter strategies
retry.jitterStrategy controls how randomness is added to the computed backoff delay (it doesn't affect Retry-After, which is always used as-is):
"fixed"(default) —delayMs * backoffMultiplier^(attempt-1)plus up to +10% random jitter on top. Backward compatible; the delay never falls below the pure backoff value."full"—delay = random(0, delayMs * backoffMultiplier^(attempt-1)). Better at spreading out many concurrent retriers (avoids synchronized retry storms hitting the backend at the same instant), at the cost of individual delays sometimes being much shorter than the nominal backoff."decorrelated"—delay = min(cap, random(delayMs, prevDelay * 3)), whereprevDelaystarts atdelayMsand updates after each attempt, andcapisdelayMs * backoffMultiplier^attempts. Spreads out concurrent retriers even better than"full"since each client's next delay depends on its own previous one.
Both algorithms are from AWS's Exponential Backoff and Jitter post — use them when many instances of your app may retry against the same backend at once.
const executor = new RequestExecutor({
baseURL: 'https://api.example.com',
retry: { attempts: 5, delayMs: 200, backoffMultiplier: 2, jitterStrategy: 'full' },
})Circuit breaker
Protect a failing backend (and your own app) from piling up retries/timeouts: 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.