HttpClient
The main client class that orchestrates caching, deduplication, and rate limiting.
Import
Section titled “Import”import { HttpClient } from '@http-client-toolkit/core';Constructor
Section titled “Constructor”new HttpClient(options);Options
Section titled “Options”name is required. All other fields are optional — pass only what you need.
| Property | Type | Default | Description |
|---|---|---|---|
name | string | required | Name for the client instance |
cache | HttpClientCacheOptions | — | Cache configuration (see below) |
dedupe | DedupeStore<T> | — | Request deduplication |
rateLimit | HttpClientRateLimitOptions | — | Rate limit configuration (see below) |
resourceKeyResolver | (url: string) => string | URL origin | Resolve the logical rate-limit resource key for a URL |
fetchFn | (url: string, init?: RequestInit) => Promise<Response> | globalThis.fetch | Custom fetch implementation |
requestInterceptor | (url: string, init: RequestInit) => Promise<RequestInit> | RequestInit | — | Pre-request hook to modify the outgoing request |
responseInterceptor | (response: Response, url: string) => Promise<Response> | Response | — | Post-response hook to inspect/modify the raw Response |
responseTransformer | (data: unknown) => unknown | — | Transform parsed response data before caching (e.g. snake_case to camelCase) |
responseHandler | (data: unknown) => unknown | — | Post-transformation hook for validation or domain-level error detection. Throw to reject 2xx responses with application-level errors |
errorHandler | (context: HttpErrorContext) => Error | — | Convert HTTP errors to domain-specific types. Context includes url, response status, parsed data, and headers. Not called for network failures |
retry | RetryOptions | false | — | Automatic retry configuration. See Retries guide |
observability | HttpClientObservabilityOptions | — | Subscribe to read-only structured lifecycle events |
cache Options (HttpClientCacheOptions)
Section titled “cache Options (HttpClientCacheOptions)”| Property | Type | Default | Description |
|---|---|---|---|
store | CacheStore<T> | required | Cache store instance |
globalScope | boolean | false | When true, cache keys are not prefixed with the client name. By default, keys are prefixed with name: to isolate each client’s cache entries |
ttl | number | 3600 | Cache TTL in seconds. Used when response has no cache headers |
overrides | CacheOverrideOptions | — | Override specific cache header behaviors (see below) |
rateLimit Options (HttpClientRateLimitOptions)
Section titled “rateLimit Options (HttpClientRateLimitOptions)”| Property | Type | Default | Description |
|---|---|---|---|
store | RateLimitStore | AdaptiveRateLimitStore | — | Rate limit store instance (optional — server cooldown logic works without a store) |
throw | boolean | true | Throw when rate limited vs. wait |
maxWaitTime | number | 60000 | Max wait time in ms before throwing |
headers | RateLimitHeaderConfig | defaults | Configure standard/custom header names |
resourceExtractor | (url: string) => string | URL origin | Deprecated. Use resourceKeyResolver instead |
configs | RateLimitConfigMap | — | Per-resource rate limit configurations |
defaultConfig | RateLimitConfig | — | Fallback 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.
Methods
Section titled “Methods”get<T>(url, options?)
Section titled “get<T>(url, options?)”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
| Property | Type | Default | Description |
|---|---|---|---|
signal | AbortSignal | — | Cancels wait + request when aborted |
priority | 'user' | 'background' | 'background' | Used by adaptive rate-limit stores |
headers | Record<string, string> | — | Custom headers sent with the request; also used for Vary-based cache matching |
retry | RetryOptions | false | — | Per-request retry override. Pass false to disable retries for this request |
cache | PerRequestCacheOptions | — | Per-request cache options. ttl overrides constructor TTL; overrides are shallow-merged with constructor-level; tags associates the cached entry with tags for later invalidation |
Request Flow
Section titled “Request Flow”When client.get(url) is called, the request passes through each configured layer:
- Cache — Return cached response if available
- Dedupe — If an identical request is already in-flight, wait for its result
- Rate Limit — Wait or throw if the rate limit is exceeded
- Request Interceptor — Modify the outgoing request (e.g. inject auth headers)
- Fetch — Execute the HTTP request via
fetchFn(orglobalThis.fetch) - Response Interceptor — Inspect or modify the raw
Response - Retry — On transient failure, repeat steps 4–6 with exponential backoff (if configured)
- Transform & Validate — Apply
responseTransformerthenresponseHandler - Store — Cache the result, record the rate limit hit, and resolve any deduplicated waiters
See the Interceptors guide for detailed usage.
Observability
Section titled “Observability”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.
Event Types
Section titled “Event Types”| Event | When it emits |
|---|---|
request:start | A request begins, before cache, dedupe, rate-limit, or cooldown checks |
request:success | A request resolves, including cache-served and deduped responses |
request:error | A final request error is about to be thrown |
cache:hit | A fresh, no-cache override, stale-while-revalidate, or stale-if-error value is served from cache |
cache:miss | No usable cache entry is available, including Vary mismatches |
cache:stale | A cached entry exists but needs revalidation or fallback handling |
cache:revalidate | Foreground or background cache revalidation is scheduled, succeeds, returns 304, or errors |
dedupe:owner | This caller owns the upstream request for a dedupe key |
dedupe:join | This caller joins or receives an existing deduped request result |
rateLimit:wait | The client waits for store-based limits or server cooldowns |
rateLimit:throw | The client throws because a store limit or server cooldown blocks the request |
serverCooldown:set | Response headers set server cooldown state |
retry:scheduled | A retryable error schedules another attempt |
retry:exhausted | A 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.
Cache TTL Semantics
Section titled “Cache TTL Semantics”Consistent across all built-in stores:
| Value | Behavior |
|---|---|
ttlSeconds > 0 | Expires after N seconds |
ttlSeconds === 0 | Never expires (permanent) |
ttlSeconds < 0 | Immediately expired |
Header-Based Rate Limiting
Section titled “Header-Based Rate Limiting”The client respects these headers out of the box:
Retry-AfterRateLimit-Remaining/RateLimit-ResetX-RateLimit-Remaining/X-RateLimit-ResetRate-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 (
429or503), or - Remaining quota is explicitly exhausted (
remaining <= 0)
Custom Header Names
Section titled “Custom Header Names”const client = new HttpClient({ name: 'my-api', rateLimit: { headers: { retryAfter: ['RetryAfterSeconds'], remaining: ['Remaining-Requests'], reset: ['Window-Reset-Seconds'], }, },});Cache Header Support
Section titled “Cache Header Support”The client respects Cache-Control, ETag, Last-Modified, and Expires headers per RFC 9111. See the Caching guide for details.
cacheOverrides
Section titled “cacheOverrides”| Property | Type | Description |
|---|---|---|
ignoreNoStore | boolean | Cache responses even when no-store is set |
ignoreNoCache | boolean | Skip revalidation even when no-cache is set |
minimumTTL | number | Floor on header-derived freshness (seconds) |
maximumTTL | number | Cap on header-derived freshness (seconds) |
invalidateByTag(tag)
Section titled “invalidateByTag(tag)”Invalidates all cache entries associated with the given tag. Returns the number of entries removed.
const count = await client.invalidateByTag('users');invalidateByTags(tags)
Section titled “invalidateByTags(tags)”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']);flushRevalidations()
Section titled “flushRevalidations()”Waits for all pending stale-while-revalidate background fetches to complete. Useful in tests.
await client.flushRevalidations();getPendingRequestCount(resourceKey?)
Section titled “getPendingRequestCount(resourceKey?)”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 resourcesconst 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 clientconst 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.
Examples
Section titled “Examples”Cache-Only Client
Section titled “Cache-Only Client”const client = new HttpClient({ name: 'my-api', cache: { store: new InMemoryCacheStore() },});Full Stack with Adaptive Rate Limiting
Section titled “Full Stack with Adaptive Rate Limiting”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, },});With Cancellation
Section titled “With Cancellation”const controller = new AbortController();const data = await client.get(url, { signal: controller.signal });
// Cancel from elsewherecontroller.abort();With Interceptors
Section titled “With Interceptors”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; },});With Custom Fetch
Section titled “With Custom Fetch”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; },});With Response Transformation
Section titled “With Response Transformation”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; },});