From bc5ec65ea962e156e52a58b27857520c4fe17872 Mon Sep 17 00:00:00 2001 From: Ally Murray Date: Tue, 7 Jul 2026 16:45:58 +0100 Subject: [PATCH] Add pending request count and named option types --- .changeset/named-types-and-pending-count.md | 14 + .../src/content/docs/api/http-client.mdx | 58 ++- docs-site/src/content/docs/api/utilities.mdx | 26 +- packages/core/README.md | 64 +++- .../core/src/http-client/http-client.test.ts | 337 ++++++++++++++++++ packages/core/src/http-client/http-client.ts | 59 ++- packages/core/src/types/http-client.ts | 39 +- packages/core/src/types/observability.ts | 12 + 8 files changed, 559 insertions(+), 50 deletions(-) create mode 100644 .changeset/named-types-and-pending-count.md diff --git a/.changeset/named-types-and-pending-count.md b/.changeset/named-types-and-pending-count.md new file mode 100644 index 0000000..aad6ebe --- /dev/null +++ b/.changeset/named-types-and-pending-count.md @@ -0,0 +1,14 @@ +--- +'@http-client-toolkit/core': minor +--- + +Export `HttpClientObservabilityOptions` and `PerRequestCacheOptions` as named types so consuming SDKs can reference them directly instead of re-deriving inline shapes. `HttpClientOptions.observability` and the per-request `get()` `cache` option now use these named types. + +Add a synchronous `getPendingRequestCount(resourceKey?)` accessor on +`HttpClient` (and `HttpClientContract`) returning the number of in-flight +`get()` calls. Includes both executing requests and joiners waiting on a +deduplicated request. Pass a `resourceKey` (as produced by +`resourceKeyResolver`, or the URL origin by default) to scope the count to a +single rate-limit bucket; omit it for the total across all resources. +Complements the asynchronous `dedupe:owner` / `dedupe:join` observability +events as a synchronous queue-depth probe. diff --git a/docs-site/src/content/docs/api/http-client.mdx b/docs-site/src/content/docs/api/http-client.mdx index 5bcafbf..e042c64 100644 --- a/docs-site/src/content/docs/api/http-client.mdx +++ b/docs-site/src/content/docs/api/http-client.mdx @@ -35,7 +35,7 @@ new HttpClient(options); | `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](/http-client-toolkit/guides/retries/) | -| `observability` | `{ onEvent?: (event: HttpClientEvent) => void \| Promise }` | — | Subscribe to read-only structured lifecycle events | +| `observability` | `HttpClientObservabilityOptions` | — | Subscribe to read-only structured lifecycle events | #### `cache` Options (`HttpClientCacheOptions`) @@ -82,7 +82,7 @@ The `url` must be an absolute URL (e.g. `https://api.example.com/items`). | `priority` | `'user' \| 'background'` | `'background'` | Used by adaptive rate-limit stores | | `headers` | `Record` | — | 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` | `{ ttl?: number; overrides?: CacheOverrideOptions; tags?: string[] }` | — | 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 | +| `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 @@ -102,7 +102,7 @@ See the [Interceptors guide](/http-client-toolkit/guides/interceptors/) for deta ## Observability -Use `observability.onEvent` to collect request lifecycle events for logs, metrics, or tracing without wrapping internal stores or fetch calls. +Use `observability.onEvent` (typed as `HttpClientObservabilityOptions`) to collect request lifecycle events for logs, metrics, or tracing without wrapping internal stores or fetch calls. ```typescript import { HttpClient, type HttpClientEvent } from '@http-client-toolkit/core'; @@ -240,6 +240,58 @@ Waits for all pending `stale-while-revalidate` background fetches to complete. U await client.flushRevalidations(); ``` +### `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. + +```typescript +// 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: + +```typescript +// 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: + +```typescript +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. + ## Examples ### Cache-Only Client diff --git a/docs-site/src/content/docs/api/utilities.mdx b/docs-site/src/content/docs/api/utilities.mdx index b40f91d..63cd4cf 100644 --- a/docs-site/src/content/docs/api/utilities.mdx +++ b/docs-site/src/content/docs/api/utilities.mdx @@ -56,15 +56,17 @@ try { The `@http-client-toolkit/core` package exports: -| Export | Type | Description | -| ---------------------------- | --------- | ------------------------------------------------------------------- | -| `HttpClient` | Class | Main client class | -| `HttpClientError` | Class | Error class with `statusCode` | -| `HttpClientEvent` | Type | Stable structured lifecycle event union for `observability.onEvent` | -| `hashRequest` | Function | Deterministic SHA-256 request hashing | -| `HttpClientCacheOptions` | Interface | Cache configuration for `HttpClientOptions.cache` | -| `HttpClientRateLimitOptions` | Interface | Rate limit configuration for `HttpClientOptions.rateLimit` | -| `CacheStore` | Interface | Cache store contract | -| `DedupeStore` | Interface | Deduplication store contract | -| `RateLimitStore` | Interface | Rate limit store contract | -| `AdaptiveRateLimitStore` | Interface | Adaptive rate limit store contract | +| Export | Type | Description | +| ----------------------------- | --------- | ---------------------------------------------------------------------------------- | +| `HttpClient` | Class | Main client class | +| `HttpClientError` | Class | Error class with `statusCode` | +| `HttpClientEvent` | Type | Stable structured lifecycle event union for `observability.onEvent` | +| `HttpClientObservabilityOptions` | Type | Named type for `HttpClientOptions.observability` | +| `PerRequestCacheOptions` | Type | Named type for the per-request `cache` option on `get()` | +| `hashRequest` | Function | Deterministic SHA-256 request hashing | +| `HttpClientCacheOptions` | Interface | Cache configuration for `HttpClientOptions.cache` | +| `HttpClientRateLimitOptions` | Interface | Rate limit configuration for `HttpClientOptions.rateLimit` | +| `CacheStore` | Interface | Cache store contract | +| `DedupeStore` | Interface | Deduplication store contract | +| `RateLimitStore` | Interface | Rate limit store contract | +| `AdaptiveRateLimitStore` | Interface | Adaptive rate limit store contract | diff --git a/packages/core/README.md b/packages/core/README.md index d4e8a90..b8a4625 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -82,23 +82,23 @@ Create a thin wrapper module per third-party API so callers don't configure anyt **Constructor options:** -| Property | Type | Default | Description | -| --------------------- | ----------------------------------------------------------------- | ---------- | -------------------------------------------------- | -| `name` | `string` | required | Name for the client instance | -| `cache` | `CacheStore` | - | Response caching | -| `dedupe` | `DedupeStore` | - | Request deduplication | -| `rateLimit` | `RateLimitStore \| AdaptiveRateLimitStore` | - | Rate limiting | -| `cacheTTL` | `number` | `3600` | Cache TTL when response has no headers | -| `throwOnRateLimit` | `boolean` | `true` | Throw when rate limited vs. wait | -| `maxWaitTime` | `number` | `60000` | Max wait time (ms) before throwing | -| `responseTransformer` | `(data: unknown) => unknown` | - | Transform raw response data | -| `responseHandler` | `(data: unknown) => unknown` | - | Validate/process transformed data | -| `errorHandler` | `(error: unknown) => Error` | - | Convert errors to domain-specific types | -| `cacheOverrides` | `CacheOverrideOptions` | - | Override cache header behaviors | -| `retry` | `RetryOptions \| false` | - | Retry config; `false` disables globally | -| `rateLimitHeaders` | `RateLimitHeaderConfig` | defaults | Configure standard/custom header names | -| `resourceKeyResolver` | `(url: string) => string` | URL origin | Customize how rate-limit resource keys are derived | -| `observability` | `{ onEvent?: (event: HttpClientEvent) => void \| Promise }` | - | Subscribe to structured lifecycle events | +| Property | Type | Default | Description | +| --------------------- | ------------------------------------------ | ---------- | -------------------------------------------------- | +| `name` | `string` | required | Name for the client instance | +| `cache` | `CacheStore` | - | Response caching | +| `dedupe` | `DedupeStore` | - | Request deduplication | +| `rateLimit` | `RateLimitStore \| AdaptiveRateLimitStore` | - | Rate limiting | +| `cacheTTL` | `number` | `3600` | Cache TTL when response has no headers | +| `throwOnRateLimit` | `boolean` | `true` | Throw when rate limited vs. wait | +| `maxWaitTime` | `number` | `60000` | Max wait time (ms) before throwing | +| `responseTransformer` | `(data: unknown) => unknown` | - | Transform raw response data | +| `responseHandler` | `(data: unknown) => unknown` | - | Validate/process transformed data | +| `errorHandler` | `(error: unknown) => Error` | - | Convert errors to domain-specific types | +| `cacheOverrides` | `CacheOverrideOptions` | - | Override cache header behaviors | +| `retry` | `RetryOptions \| false` | - | Retry config; `false` disables globally | +| `rateLimitHeaders` | `RateLimitHeaderConfig` | defaults | Configure standard/custom header names | +| `resourceKeyResolver` | `(url: string) => string` | URL origin | Customize how rate-limit resource keys are derived | +| `observability` | `HttpClientObservabilityOptions` | - | Subscribe to structured lifecycle events | ### Request Flow @@ -202,8 +202,8 @@ const client = new HttpClient({ ### Custom Rate-Limit Buckets -Use `resourceKeyResolver` when list and retrieve routes should share the same -rate-limit bucket: +Use `resourceKeyResolver` when REST routes should map to endpoint-level +rate-limit buckets instead of the default origin-level bucket: ```typescript const client = new HttpClient({ @@ -213,6 +213,9 @@ const client = new HttpClient({ if (path === '/api/issues' || path.startsWith('/api/issue/')) { return 'issues'; } + if (path.startsWith('/api/users')) { + return 'users'; + } return new URL(url).origin; }, rateLimit: { @@ -229,9 +232,32 @@ compatibility. - `HttpClient` - Main client class - `HttpClientError` - Error class with `statusCode` - `HttpClientEvent` - Stable structured lifecycle event union +- `HttpClientObservabilityOptions` - Named type for `HttpClientOptions.observability` +- `PerRequestCacheOptions` - Named type for `get()` `cache` option shape - `hashRequest` - Deterministic SHA-256 request hashing - Store interfaces: `CacheStore`, `DedupeStore`, `RateLimitStore`, `AdaptiveRateLimitStore` +`HttpClient` exposes a synchronous `getPendingRequestCount(resourceKey?)` +accessor that returns the number of `get()` calls currently in-flight on the +client. This includes both requests being executed and requests joined onto an +in-flight deduplicated request. + +Pass a `resourceKey` as produced by `resourceKeyResolver` to scope the count to +a single rate-limit bucket. `HttpClient` is not bound to a base URL, so with the +default resolver that key is derived from each request URL's origin and all +paths on the same origin share one bucket; omit the key for the total across +all resources on the client. For per-endpoint REST backpressure, configure +`resourceKeyResolver` to return finer-grained keys for those routes. Use this +accessor as a synchronous queue-depth probe complementing the asynchronous +`dedupe:owner` / `dedupe:join` observability events. + +```typescript +// With the resolver above, these are separate endpoint-level buckets. +const pendingIssues = client.getPendingRequestCount('issues'); +const pendingUsers = client.getPendingRequestCount('users'); +const pendingTotal = client.getPendingRequestCount(); +``` + ## License ISC diff --git a/packages/core/src/http-client/http-client.test.ts b/packages/core/src/http-client/http-client.test.ts index fbe0d1f..fac8deb 100644 --- a/packages/core/src/http-client/http-client.test.ts +++ b/packages/core/src/http-client/http-client.test.ts @@ -1574,6 +1574,343 @@ describe('HttpClient', () => { expect(calls.fail).toBe(1); }); + describe('getPendingRequestCount', () => { + test('returns 0 for a fresh client with and without a resource key', () => { + expect(httpClient.getPendingRequestCount()).toBe(0); + expect(httpClient.getPendingRequestCount(baseUrl)).toBe(0); + }); + + test('request:start observers see the current request counted', async () => { + const startCounts: Array = []; + const holder: { client?: HttpClient } = {}; + + const client = new HttpClient({ + name: 'test', + fetchFn: async () => new Response('{}', { status: 200 }), + observability: { + onEvent: (event) => { + if (event.type === 'request:start') { + startCounts.push( + holder.client!.getPendingRequestCount(event.resourceKey), + ); + } + }, + }, + }); + holder.client = client; + + await client.get(`${baseUrl}/observed-pending`); + + expect(startCounts).toEqual([1]); + }); + + test('tracks a single in-flight request and resets on success', async () => { + let resolveFetch!: (value: Response) => void; + const fetchPromise = new Promise((resolve) => { + resolveFetch = resolve; + }); + + const client = new HttpClient({ + name: 'test', + fetchFn: () => fetchPromise, + }); + + const requestPromise = client.get(`${baseUrl}/pending`); + + // Yield so the request enters the try block before we probe. + await Promise.resolve(); + await Promise.resolve(); + expect(client.getPendingRequestCount()).toBe(1); + expect(client.getPendingRequestCount(baseUrl)).toBe(1); + + resolveFetch( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + await requestPromise; + expect(client.getPendingRequestCount()).toBe(0); + expect(client.getPendingRequestCount(baseUrl)).toBe(0); + }); + + test('resets to 0 after a request throws', async () => { + let resolveFetch!: (value: Response) => void; + const fetchPromise = new Promise((resolve) => { + resolveFetch = resolve; + }); + + const client = new HttpClient({ + name: 'test', + fetchFn: () => fetchPromise, + }); + + const requestPromise = client.get(`${baseUrl}/pending-error`); + + await Promise.resolve(); + await Promise.resolve(); + expect(client.getPendingRequestCount()).toBe(1); + expect(client.getPendingRequestCount(baseUrl)).toBe(1); + + resolveFetch(new Response('boom', { status: 500 })); + + await expect(requestPromise).rejects.toThrow(HttpClientError); + expect(client.getPendingRequestCount()).toBe(0); + expect(client.getPendingRequestCount(baseUrl)).toBe(0); + }); + + test('counts both owner and joiner for in-flight deduped requests', async () => { + type Deferred = { + promise: Promise; + resolve: (value: unknown) => void; + reject: (reason: unknown) => void; + }; + + let resolveFetch!: (value: Response) => void; + const fetchPromise = new Promise((resolve) => { + resolveFetch = resolve; + }); + + let resolveOwnerRegistered!: () => void; + const ownerRegistered = new Promise((resolve) => { + resolveOwnerRegistered = resolve; + }); + + let resolveJoinerWaiting!: () => void; + const joinerWaiting = new Promise((resolve) => { + resolveJoinerWaiting = resolve; + }); + + let job: Deferred | undefined; + let waitForCalls = 0; + let registerOrJoinCalls = 0; + + const dedupeStoreStub = { + async waitFor() { + waitForCalls += 1; + + if (waitForCalls <= 2) { + return undefined; + } + + resolveJoinerWaiting(); + + try { + return await job!.promise; + } catch { + return undefined; + } + }, + async registerOrJoin() { + registerOrJoinCalls += 1; + + if (!job) { + let resolve!: (value: unknown) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + job = { promise, resolve, reject }; + resolveOwnerRegistered(); + return { jobId: 'owner-job', isOwner: true }; + } + + return { jobId: 'owner-job', isOwner: false }; + }, + async complete(_hash: string, _value: unknown) { + job?.resolve(_value); + }, + async fail(_hash: string, error: Error) { + job?.reject(error); + }, + async isInProgress() { + return job !== undefined; + }, + } as const; + + const client = new HttpClient({ + name: 'test', + dedupe: dedupeStoreStub, + fetchFn: () => fetchPromise, + }); + + const ownerRequest = client.get(`${baseUrl}/dedupe-pending`); + await ownerRegistered; + + // Start a second identical request which will join the in-flight owner. + const joinerRequest = client.get(`${baseUrl}/dedupe-pending`); + await joinerWaiting; + + expect(client.getPendingRequestCount()).toBe(2); + expect(client.getPendingRequestCount(baseUrl)).toBe(2); + expect(registerOrJoinCalls).toBe(2); + + resolveFetch(new Response('{}', { status: 200 })); + await expect(Promise.all([ownerRequest, joinerRequest])).resolves.toEqual( + [{}, {}], + ); + expect(client.getPendingRequestCount()).toBe(0); + expect(client.getPendingRequestCount(baseUrl)).toBe(0); + }); + + test('resets to 0 after a cache hit returns synchronously', async () => { + const store = new Map(); + const cache = { + async get(hash: string) { + return store.get(hash); + }, + async set(hash: string, value: unknown) { + store.set(hash, value); + }, + async setWithTags(hash: string, value: unknown) { + store.set(hash, value); + }, + async invalidateByTag() { + return 0; + }, + async invalidateByTags() { + return 0; + }, + async delete(hash: string) { + store.delete(hash); + }, + async clear() { + store.clear(); + }, + } as const; + + const client = new HttpClient({ + name: 'test', + cache: { store: cache }, + }); + + nock(baseUrl) + .get('/cached-pending') + .reply(200, { ok: true }, { 'Cache-Control': 'max-age=60' }); + + // Prime the cache so the next call is a cache hit. + await client.get(`${baseUrl}/cached-pending`); + expect(client.getPendingRequestCount()).toBe(0); + + await client.get(`${baseUrl}/cached-pending`); + expect(client.getPendingRequestCount()).toBe(0); + }); + + test('scopes per-resource counts using resourceKeyResolver output', async () => { + const ownerResource = 'https://api.example.com'; + const altResource = 'https://api-alt.example.com'; + + const fetches: Array<{ + resource: string; + resolve: (value: Response) => void; + }> = []; + + function makeFetch(resource: string) { + return () => + new Promise((resolve) => { + fetches.push({ resource, resolve }); + }); + } + + // Two clients, each with its own resource so the per-resource counts + // are tracked independently. We use a custom resourceKeyResolver to + // keep the resource keys deterministic. + const clientA = new HttpClient({ + name: 'a', + resourceKeyResolver: (url) => new URL(url).origin, + fetchFn: makeFetch(ownerResource), + }); + const clientB = new HttpClient({ + name: 'b', + resourceKeyResolver: (url) => new URL(url).origin, + fetchFn: makeFetch(altResource), + }); + + const reqA = clientA.get(`${baseUrl}/pending-a`); + const reqB = clientB.get(`${alternateBaseUrl}/pending-b`); + + await Promise.resolve(); + await Promise.resolve(); + + // Each client only sees its own resource. + expect(clientA.getPendingRequestCount(ownerResource)).toBe(1); + expect(clientA.getPendingRequestCount(altResource)).toBe(0); + expect(clientA.getPendingRequestCount()).toBe(1); + expect(clientB.getPendingRequestCount(altResource)).toBe(1); + expect(clientB.getPendingRequestCount(ownerResource)).toBe(0); + expect(clientB.getPendingRequestCount()).toBe(1); + + fetches + .find((f) => f.resource === ownerResource)! + .resolve(new Response('{}', { status: 200 })); + fetches + .find((f) => f.resource === altResource)! + .resolve(new Response('{}', { status: 200 })); + + await Promise.all([reqA, reqB]); + + expect(clientA.getPendingRequestCount(ownerResource)).toBe(0); + expect(clientA.getPendingRequestCount()).toBe(0); + expect(clientB.getPendingRequestCount(altResource)).toBe(0); + expect(clientB.getPendingRequestCount()).toBe(0); + }); + + test('returns total across multiple resources when no key is passed', async () => { + const fetches = new Map void>(); + + const client = new HttpClient({ + name: 'multi', + resourceKeyResolver: (url) => new URL(url).origin, + fetchFn: (url) => + new Promise((resolve) => { + fetches.set(url, resolve); + }), + }); + + const reqA = client.get(`${baseUrl}/a`); + const reqB = client.get(`${alternateBaseUrl}/b`); + + await Promise.resolve(); + await Promise.resolve(); + + expect(client.getPendingRequestCount(baseUrl)).toBe(1); + expect(client.getPendingRequestCount(alternateBaseUrl)).toBe(1); + expect(client.getPendingRequestCount()).toBe(2); + + fetches.get(`${baseUrl}/a`)!(new Response('{}', { status: 200 })); + await reqA; + + expect(client.getPendingRequestCount(baseUrl)).toBe(0); + expect(client.getPendingRequestCount(alternateBaseUrl)).toBe(1); + expect(client.getPendingRequestCount()).toBe(1); + + fetches.get(`${alternateBaseUrl}/b`)!( + new Response('{}', { status: 200 }), + ); + await reqB; + + expect(client.getPendingRequestCount()).toBe(0); + }); + + test('new named types are re-exported from the package entrypoint', async () => { + const mod = await import('../index.js'); + expect(mod.HttpClient).toBeTypeOf('function'); + // Type-level smoke check: ensure the new named types resolve at compile time. + type _ObservabilityCheck = NonNullable< + ConstructorParameters[0] + >['observability']; + type _PerRequestCacheCheck = NonNullable< + Parameters<(typeof mod.HttpClient)['prototype']['get']>[1] + >['cache']; + const _o: _ObservabilityCheck = { onEvent: () => {} }; + const _c: _PerRequestCacheCheck = { ttl: 30 }; + expect(_o).toBeDefined(); + expect(_c).toBeDefined(); + }); + }); + test('should wrap Error in HttpClientError via generateClientError', () => { const client = new HttpClient({ name: 'test' }) as unknown as { generateClientError: (err: unknown) => Error; diff --git a/packages/core/src/http-client/http-client.ts b/packages/core/src/http-client/http-client.ts index ea43763..9d5589c 100644 --- a/packages/core/src/http-client/http-client.ts +++ b/packages/core/src/http-client/http-client.ts @@ -24,8 +24,10 @@ import { import { HttpClientContract, type CacheOverrideOptions, - type HttpErrorContext, type HttpClientEvent, + type HttpClientObservabilityOptions, + type HttpErrorContext, + type PerRequestCacheOptions, type RetryContext, type RetryOptions, } from '../types/index.js'; @@ -204,9 +206,7 @@ export interface HttpClientOptions { */ retry?: RetryOptions | false; /** Structured lifecycle events for metrics, logs, and tracing. */ - observability?: { - onEvent?: (event: HttpClientEvent) => void | Promise; - }; + observability?: HttpClientObservabilityOptions; } interface RateLimitHeaderConfig { @@ -241,8 +241,10 @@ interface EventBaseFields { export { type CacheOverrideOptions, - type HttpErrorContext, type HttpClientEvent, + type HttpClientObservabilityOptions, + type HttpErrorContext, + type PerRequestCacheOptions, type RetryContext, type RetryOptions, }; @@ -253,6 +255,7 @@ export class HttpClient implements HttpClientContract { private serverCooldowns = new Map(); private pendingRevalidations: Array> = []; private requestSequence = 0; + private pendingRequestCounts = new Map(); private options: { cacheTTL: number; throwOnRateLimit: boolean; @@ -905,6 +908,33 @@ export class HttpClient implements HttpClientContract { this.pendingRevalidations = []; } + /** + * Synchronous count of `get()` calls currently in-flight on this client. + * + * Includes requests being executed by this client as well as requests + * waiting on an in-flight deduplicated request. This is the basis for a + * synchronous queue-depth probe, complementing the asynchronous + * `dedupe:owner` / `dedupe:join` observability events. + * + * Pass a `resourceKey` to scope the count to a single rate-limit bucket + * (as produced by `resourceKeyResolver`, or the URL origin by default). + * Omit it for the total across all resources. + * + * @param resourceKey Optional rate-limit resource key to scope the count. + * @returns The number of in-flight requests (for the given resource, or total). + */ + getPendingRequestCount(resourceKey?: string): number { + if (resourceKey !== undefined) { + return this.pendingRequestCounts.get(resourceKey) ?? 0; + } + + let total = 0; + for (const count of this.pendingRequestCounts.values()) { + total += count; + } + return total; + } + private async backgroundRevalidate( url: string, hash: string, @@ -1504,11 +1534,7 @@ export class HttpClient implements HttpClientContract { priority?: RequestPriority; headers?: Record; retry?: RetryOptions | false; - cache?: { - ttl?: number; - overrides?: CacheOverrideOptions; - tags?: Array; - }; + cache?: PerRequestCacheOptions; } = {}, ): Promise { const { signal, priority = 'background', headers } = options; @@ -1570,11 +1596,15 @@ export class HttpClient implements HttpClientContract { let staleEntry: CacheEntry | undefined; let staleCandidate: CacheEntry | undefined; + this.pendingRequestCounts.set( + resource, + (this.pendingRequestCounts.get(resource) ?? 0) + 1, + ); + this.emitEvent({ ...this.eventBase(requestContext), type: 'request:start', }); - try { await this.enforceServerCooldown(url, signal, false, requestContext); @@ -1980,6 +2010,13 @@ export class HttpClient implements HttpClientContract { this.statusFromError(error) ?? this.statusFromError(clientError), ); throw clientError; + } finally { + const next = (this.pendingRequestCounts.get(resource) ?? 1) - 1; + if (next > 0) { + this.pendingRequestCounts.set(resource, next); + } else { + this.pendingRequestCounts.delete(resource); + } } } } diff --git a/packages/core/src/types/http-client.ts b/packages/core/src/types/http-client.ts index 1bdfb80..b08e340 100644 --- a/packages/core/src/types/http-client.ts +++ b/packages/core/src/types/http-client.ts @@ -53,6 +53,23 @@ export interface CacheOverrideOptions { maximumTTL?: number; } +/** + * Per-request cache configuration passed to `HttpClient.get()`. + * + * - `ttl` overrides the constructor-level TTL for this request only. + * - `overrides` is shallow-merged with constructor-level cache overrides. + * - `tags` associates the resulting cache entry with tags for selective + * invalidation via `invalidateByTag()` / `invalidateByTags()`. + */ +export interface PerRequestCacheOptions { + /** TTL in seconds — overrides the constructor-level TTL for this request. */ + ttl?: number; + /** Cache overrides shallow-merged with constructor-level overrides. */ + overrides?: CacheOverrideOptions; + /** Tags associated with the cache entry for selective invalidation. */ + tags?: Array; +} + export interface HttpClientContract { /** Name identifying this client instance. */ readonly name: string; @@ -68,6 +85,22 @@ export interface HttpClientContract { * @returns The number of cache entries that were deleted, or 0 if no cache store is configured. */ invalidateByTags(tags: Array): Promise; + /** + * Synchronous count of `get()` calls currently in-flight on this client. + * + * Includes requests being executed by this client as well as requests + * joined onto an in-flight deduplicated request. Useful as a queue-depth + * probe for backpressure decisions, complementing the asynchronous + * `dedupe:owner` / `dedupe:join` observability events. + * + * Pass a `resourceKey` (as produced by `resourceKeyResolver`, or the URL + * origin by default) to scope the count to a single rate-limit bucket. + * Omit it for the total across all resources. + * + * @param resourceKey Optional rate-limit resource key to scope the count. + * @returns The number of in-flight requests (for the given resource, or total). + */ + getPendingRequestCount(resourceKey?: string): number; /** * Perform a GET request. * @@ -103,11 +136,7 @@ export interface HttpClientContract { * Per-request cache options. `ttl` overrides constructor-level TTL; * `overrides` are shallow-merged with constructor-level cache overrides. */ - cache?: { - ttl?: number; - overrides?: CacheOverrideOptions; - tags?: Array; - }; + cache?: PerRequestCacheOptions; }, ): Promise; } diff --git a/packages/core/src/types/observability.ts b/packages/core/src/types/observability.ts index 72deedd..281ca9d 100644 --- a/packages/core/src/types/observability.ts +++ b/packages/core/src/types/observability.ts @@ -172,3 +172,15 @@ export type HttpClientEvent = | HttpClientServerCooldownSetEvent | HttpClientRetryScheduledEvent | HttpClientRetryExhaustedEvent; + +/** + * Observability configuration for an `HttpClient` instance. + * + * Observers receive structured lifecycle events covering requests, cache, + * dedupe, rate limiting, server cooldowns, and retries. Observer return values + * are ignored and observer errors are swallowed so observability can never + * affect request behaviour. + */ +export interface HttpClientObservabilityOptions { + onEvent?: (event: HttpClientEvent) => void | Promise; +}