Skip to content

REST Client

createRestClient

ts
createRestClient(config: HttpConfig): RestClient

Creates a REST client with advanced HTTP features.

Methods

MethodDescription
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

OptionDescription
baseURLBase URL for all requests
timeoutRequest timeout in ms
headersDefault headers
withCredentialsInclude cookies
retry.attemptsNumber of retry attempts
retry.delayMsBase delay between retries in ms
retry.backoffMultiplierExponential backoff multiplier
retry.retriableStatusHTTP status codes eligible for retry (e.g. [429, 500, 503])
retry.maxRetryAfterMsMax wait from Retry-After header in ms (default: 60000)
retry.jitterStrategyBackoff jitter algorithm: "fixed" (default), "full", or "decorrelated"
cache.enabledEnable response caching for GET requests
cache.ttlMsCache TTL in ms
cache.strategy"strict" (default) or "stale-while-revalidate"
cache.staleMsExtra time after ttlMs a stale response may still be served (SWR strategy)
cache.storeCustom CacheStore backend (e.g. Redis) instead of the built-in in-memory TtlCache — see Custom cache backend
rateLimit.maxConcurrentMax simultaneous requests
rateLimit.maxRequestsPerIntervalMax requests per time window
rateLimit.intervalMsTime window size in ms
rateLimit.storeCustom RateLimiterStore backend (e.g. Redis) for a limit shared across server instances — see Distributed rate limiting
rateLimit.keyBucket name when using a shared rateLimit.store (default: random per-instance id — without an explicit key, a store has no sharing effect)
rateLimit.leaseMsAuto-expiry (ms) for a store-backed concurrency slot if its holder crashes without releasing (default: 30000)
rateLimit.onRateLimitHeadersCallback 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.onRequestStartCallback on request start
metrics.onRequestEndCallback on request end (includes duration and bytes)
auth.getTokenAsync function returning a Bearer token (called before every request, unless auth.tokenTtlMs is set)
auth.onUnauthorizedOptional async callback on 401 — refresh the token here; request is retried once
auth.tokenTtlMsCache getToken()'s result for this many ms instead of calling it before every request; invalidated automatically on 401
sanitizeHeadersMask sensitive headers in metrics callbacks (default: true — secure by default)
sensitiveHeadersAdditional headers to mask (extends DEFAULT_SENSITIVE_HEADERS)
adapterCustom HTTP adapter (e.g. native fetch) — replaces built-in axios
circuitBreakerSee Circuit breaker{ failureThreshold, openMs, successThreshold?, isFailure?, store?, key? }
tracing.generateTraceparentAdd a W3C traceparent header to every request (default: false) — see Request tracing
tracing.providerTracingProvider hook creating a span per request — see Request tracing
idempotencyHeaderNameHeader name used for RestRequestConfig.idempotencyKey (default: "Idempotency-Key") — see Idempotency keys
autoIdempotencyKeyHave RequestExecutor auto-generate an idempotency key per logical request (default: false) — see Idempotency keys
offlineQueueQueue 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

js
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:

js
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):

js
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:

ts
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

js
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):

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 — see RequestExecutor; createRestClient()'s own client.post()/etc. don't retry on their own) can generate the key for you automatically:

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:

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:

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

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.

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" }

RequestExecutor

Wrapper for REST requests with retry, timeout (via AbortController), Retry-After header support, and backoff.

js
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)), where prevDelay starts at delayMs and updates after each attempt, and cap is delayMs * 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.

js
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.

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.