Skip to content

HttpClient

The main client class that orchestrates caching, deduplication, and rate limiting.

import { HttpClient } from '@http-client-toolkit/core';
new HttpClient(options);

name is required. All other fields are optional — pass only what you need.

PropertyTypeDefaultDescription
namestringrequiredName for the client instance
cacheHttpClientCacheOptionsCache configuration (see below)
dedupeDedupeStore<T>Request deduplication
rateLimitHttpClientRateLimitOptionsRate limit configuration (see below)
resourceKeyResolver(url: string) => stringURL originResolve the logical rate-limit resource key for a URL
fetchFn(url: string, init?: RequestInit) => Promise<Response>globalThis.fetchCustom fetch implementation
requestInterceptor(url: string, init: RequestInit) => Promise<RequestInit> | RequestInitPre-request hook to modify the outgoing request
responseInterceptor(response: Response, url: string) => Promise<Response> | ResponsePost-response hook to inspect/modify the raw Response
responseTransformer(data: unknown) => unknownTransform parsed response data before caching (e.g. snake_case to camelCase)
responseHandler(data: unknown) => unknownPost-transformation hook for validation or domain-level error detection. Throw to reject 2xx responses with application-level errors
errorHandler(context: HttpErrorContext) => ErrorConvert HTTP errors to domain-specific types. Context includes url, response status, parsed data, and headers. Not called for network failures
retryRetryOptions | falseAutomatic retry configuration. See Retries guide
observabilityHttpClientObservabilityOptionsSubscribe to read-only structured lifecycle events
PropertyTypeDefaultDescription
storeCacheStore<T>requiredCache store instance
globalScopebooleanfalseWhen true, cache keys are not prefixed with the client name. By default, keys are prefixed with name: to isolate each client’s cache entries
ttlnumber3600Cache TTL in seconds. Used when response has no cache headers
overridesCacheOverrideOptionsOverride specific cache header behaviors (see below)

rateLimit Options (HttpClientRateLimitOptions)

Section titled “rateLimit Options (HttpClientRateLimitOptions)”
PropertyTypeDefaultDescription
storeRateLimitStore | AdaptiveRateLimitStoreRate limit store instance (optional — server cooldown logic works without a store)
throwbooleantrueThrow when rate limited vs. wait
maxWaitTimenumber60000Max wait time in ms before throwing
headersRateLimitHeaderConfigdefaultsConfigure standard/custom header names
resourceExtractor(url: string) => stringURL originDeprecated. Use resourceKeyResolver instead
configsRateLimitConfigMapPer-resource rate limit configurations
defaultConfigRateLimitConfigFallback rate limit config when no per-resource config matches

resourceKeyResolver applies everywhere the client performs rate-limit accounting, including store checks and server cooldowns. rateLimit.resourceExtractor is deprecated, retained for compatibility, and only used when resourceKeyResolver is not provided.

Makes a GET request through the configured pipeline.

const data = await client.get<{ name: string }>(
'https://api.example.com/user/1',
);

The url must be an absolute URL (e.g. https://api.example.com/items).

Request Options

PropertyTypeDefaultDescription
signalAbortSignalCancels wait + request when aborted
priority'user' | 'background''background'Used by adaptive rate-limit stores
headersRecord<string, string>Custom headers sent with the request; also used for Vary-based cache matching
retryRetryOptions | falsePer-request retry override. Pass false to disable retries for this request
cachePerRequestCacheOptionsPer-request cache options. ttl overrides constructor TTL; overrides are shallow-merged with constructor-level; tags associates the cached entry with tags for later invalidation

When client.get(url) is called, the request passes through each configured layer:

  1. Cache — Return cached response if available
  2. Dedupe — If an identical request is already in-flight, wait for its result
  3. Rate Limit — Wait or throw if the rate limit is exceeded
  4. Request Interceptor — Modify the outgoing request (e.g. inject auth headers)
  5. Fetch — Execute the HTTP request via fetchFn (or globalThis.fetch)
  6. Response Interceptor — Inspect or modify the raw Response
  7. Retry — On transient failure, repeat steps 4–6 with exponential backoff (if configured)
  8. Transform & Validate — Apply responseTransformer then responseHandler
  9. Store — Cache the result, record the rate limit hit, and resolve any deduplicated waiters

See the Interceptors guide for detailed usage.

Use observability.onEvent (typed as HttpClientObservabilityOptions) to collect request lifecycle events for logs, metrics, or tracing without wrapping internal stores or fetch calls.

import { HttpClient, type HttpClientEvent } from '@http-client-toolkit/core';
const client = new HttpClient({
name: 'catalog-api',
observability: {
onEvent(event: HttpClientEvent) {
logger.info({ event }, event.type);
if (event.type === 'request:success') {
metrics.histogram('http_client_duration_ms', event.durationMs, {
clientName: event.clientName,
status: String(event.status ?? 'unknown'),
});
}
},
},
});

onEvent is a read-only observer. It is not an interceptor or middleware hook: return values are ignored, synchronous errors are swallowed, and rejected promises are caught without awaiting them in the hot request path. Keep synchronous observer work lightweight because it runs inline before any returned promise is detached. Observers cannot change the request result or thrown error.

EventWhen it emits
request:startA request begins, before cache, dedupe, rate-limit, or cooldown checks
request:successA request resolves, including cache-served and deduped responses
request:errorA final request error is about to be thrown
cache:hitA fresh, no-cache override, stale-while-revalidate, or stale-if-error value is served from cache
cache:missNo usable cache entry is available, including Vary mismatches
cache:staleA cached entry exists but needs revalidation or fallback handling
cache:revalidateForeground or background cache revalidation is scheduled, succeeds, returns 304, or errors
dedupe:ownerThis caller owns the upstream request for a dedupe key
dedupe:joinThis caller joins or receives an existing deduped request result
rateLimit:waitThe client waits for store-based limits or server cooldowns
rateLimit:throwThe client throws because a store limit or server cooldown blocks the request
serverCooldown:setResponse headers set server cooldown state
retry:scheduledA retryable error schedules another attempt
retry:exhaustedA retry sequence reaches its final failed attempt

All events include stable public fields such as type, clientName, requestId, url, method, resourceKey, and timestamp. Events add context-specific fields like attempt, durationMs, status, error, cacheKey, and waitMs where applicable. These payloads are public API.

Attempt numbers are emitted on retry events (retry:scheduled and retry:exhausted). Final request:success and request:error events describe the logical request outcome and do not include a fetch attempt count.

Bridge onEvent to your logger, metrics client, or OpenTelemetry instrumentation by translating events into log records, counters, histograms, span events, or attributes. OpenTelemetry is intentionally not a dependency of @http-client-toolkit/core.

Consistent across all built-in stores:

ValueBehavior
ttlSeconds > 0Expires after N seconds
ttlSeconds === 0Never expires (permanent)
ttlSeconds < 0Immediately expired

The client respects these headers out of the box:

  • Retry-After
  • RateLimit-Remaining / RateLimit-Reset
  • X-RateLimit-Remaining / X-RateLimit-Reset
  • Rate-Limit-Remaining / Rate-Limit-Reset
  • Combined structured RateLimit (e.g. "default";r=0;t=30)

Cooldowns are enforced when:

  • The response is a throttling status (429 or 503), or
  • Remaining quota is explicitly exhausted (remaining <= 0)
const client = new HttpClient({
name: 'my-api',
rateLimit: {
headers: {
retryAfter: ['RetryAfterSeconds'],
remaining: ['Remaining-Requests'],
reset: ['Window-Reset-Seconds'],
},
},
});

The client respects Cache-Control, ETag, Last-Modified, and Expires headers per RFC 9111. See the Caching guide for details.

PropertyTypeDescription
ignoreNoStorebooleanCache responses even when no-store is set
ignoreNoCachebooleanSkip revalidation even when no-cache is set
minimumTTLnumberFloor on header-derived freshness (seconds)
maximumTTLnumberCap on header-derived freshness (seconds)

Invalidates all cache entries associated with the given tag. Returns the number of entries removed.

const count = await client.invalidateByTag('users');

Invalidates all cache entries associated with any of the given tags. Returns the number of unique entries removed.

const count = await client.invalidateByTags(['users', 'user:123']);

Waits for all pending stale-while-revalidate background fetches to complete. Useful in tests.

await client.flushRevalidations();

Returns the number of get() calls currently in-flight on this client. Includes both requests being executed by this client and requests joined onto an in-flight deduplicated request.

// Total across all resources
const total = client.getPendingRequestCount();

Pass a resourceKey — the same value resourceKeyResolver returns — to scope the count to a single rate-limit bucket. HttpClient is not bound to a base URL, so with the default resolver the resource key is derived from each request URL’s origin and all paths on the same origin share one bucket:

// Counts only in-flight requests to this origin. This differs from
// getPendingRequestCount() only when the same client instance also has
// requests in-flight for other origins or custom resource buckets.
const github = client.getPendingRequestCount('https://api.github.com');

For REST APIs, individual resources/endpoints only get separate counts when you configure a custom resourceKeyResolver that returns finer-grained keys than the URL origin — the same pattern used for rate-limit bucketing:

import { HttpClient } from '@http-client-toolkit/core';
const client = new HttpClient({
name: 'issues-api',
resourceKeyResolver: (url) => {
const path = new URL(url).pathname;
if (path === '/api/issues' || path.startsWith('/api/issue/')) {
return 'issues';
}
if (path.startsWith('/api/users')) {
return 'users';
}
return new URL(url).origin;
},
});
// Per-bucket backpressure: throttle the issues endpoint independently
// of user lookups, even though they share a client.
if (client.getPendingRequestCount('issues') >= 50) {
throw new Error('Issues endpoint queue saturated');
}
// User lookups are tracked separately because the resolver returns 'users'.
const pendingUsers = client.getPendingRequestCount('users');
// Total load across the whole client
const total = client.getPendingRequestCount();

This is a synchronous queue-depth probe complementing the asynchronous dedupe:owner / dedupe:join observability events — useful for backpressure, concurrency limiting, or scheduling decisions where waiting for an event would be too late.

const client = new HttpClient({
name: 'my-api',
cache: { store: new InMemoryCacheStore() },
});
const client = new HttpClient({
name: 'my-api',
cache: { store: new SQLiteCacheStore({ database: db }), ttl: 600 },
dedupe: new SQLiteDedupeStore({ database: db }),
rateLimit: {
store: new SqliteAdaptiveRateLimitStore({
database: db,
defaultConfig: { limit: 200, windowMs: 3_600_000 },
}),
throw: false,
maxWaitTime: 30_000,
},
});
const controller = new AbortController();
const data = await client.get(url, { signal: controller.signal });
// Cancel from elsewhere
controller.abort();
const client = new HttpClient({
name: 'my-api',
requestInterceptor: async (url, init) => {
const token = await getAccessToken();
const headers = new Headers(init.headers);
headers.set('Authorization', `Bearer ${token}`);
return { ...init, headers };
},
responseInterceptor: (response, url) => {
console.log(`${response.status} ${url}`);
return response;
},
});
const client = new HttpClient({
name: 'my-api',
fetchFn: async (url, init) => {
const response = await fetch(url, init);
// Follow pre-signed URL redirects before caching
if (response.headers.has('x-redirect-url')) {
return fetch(response.headers.get('x-redirect-url')!, init);
}
return response;
},
});
import camelcaseKeys from 'camelcase-keys';
const client = new HttpClient({
name: 'my-api',
responseTransformer: (data) =>
camelcaseKeys(data as Record<string, unknown>, { deep: true }),
responseHandler: (data) => {
if (!data || typeof data !== 'object') {
throw new Error('Unexpected response shape');
}
return data;
},
});