From 6e9c9bf94820424b2eac839e8a55157f7f232ffb Mon Sep 17 00:00:00 2001 From: Ally Murray Date: Mon, 6 Apr 2026 12:49:31 +0100 Subject: [PATCH 1/4] Add resourceKeyResolver for rate-limit keys --- .../src/content/docs/api/http-client.mdx | 3 + .../content/docs/guides/recommended-usage.mdx | 2 +- packages/core/README.md | 22 +++ .../core/src/http-client/http-client.test.ts | 187 +++++++++++++----- packages/core/src/http-client/http-client.ts | 44 +++-- packages/core/src/stores/rate-limit-config.ts | 2 +- 6 files changed, 196 insertions(+), 64 deletions(-) diff --git a/docs-site/src/content/docs/api/http-client.mdx b/docs-site/src/content/docs/api/http-client.mdx index 8f672e9..9d6734c 100644 --- a/docs-site/src/content/docs/api/http-client.mdx +++ b/docs-site/src/content/docs/api/http-client.mdx @@ -28,6 +28,7 @@ new HttpClient(options) | `cache` | `HttpClientCacheOptions` | — | Cache configuration (see below) | | `dedupe` | `DedupeStore` | — | 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` | `globalThis.fetch` | Custom fetch implementation | | `requestInterceptor` | `(url: string, init: RequestInit) => Promise \| RequestInit` | — | Pre-request hook to modify the outgoing request | | `responseInterceptor` | `(response: Response, url: string) => Promise \| Response` | — | Post-response hook to inspect/modify the raw Response | @@ -57,6 +58,8 @@ new HttpClient(options) | `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` remains supported for compatibility, but `resourceKeyResolver` takes precedence when both are provided. + ## Methods ### `get(url, options?)` diff --git a/docs-site/src/content/docs/guides/recommended-usage.mdx b/docs-site/src/content/docs/guides/recommended-usage.mdx index eb3772b..bee818b 100644 --- a/docs-site/src/content/docs/guides/recommended-usage.mdx +++ b/docs-site/src/content/docs/guides/recommended-usage.mdx @@ -170,7 +170,7 @@ All clients share one database and one set of stores, but cache entries are scop |---------|-------------------|---------------| | **Cache** | Private — keys prefixed with client name (e.g. `github:hash`) | Set `globalScope: true` to share cache entries across clients | | **Dedup** | Shared — uses the raw request hash with no client prefix | Dedup is always shared when clients use the same store, which is usually desirable | -| **Rate limit** | Shared by resource — tracked per URL origin, not per client | Use `resourceExtractor` to customise how resources are derived from URLs | +| **Rate limit** | Shared by resource — tracked per URL origin, not per client | Use `resourceKeyResolver` to customise how resources are derived from URLs | ### Sharing Cache Across Clients diff --git a/packages/core/README.md b/packages/core/README.md index bc78d6a..7c3e3b7 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -97,6 +97,7 @@ Create a thin wrapper module per third-party API so callers don't configure anyt | `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 | ### Request Flow @@ -155,6 +156,27 @@ const client = new HttpClient({ }); ``` +### Custom Rate-Limit Buckets + +Use `resourceKeyResolver` when list and retrieve routes should share the same +rate-limit bucket: + +```typescript +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'; + } + return new URL(url).origin; + }, + rateLimit: { + store: rateLimitStore, + }, +}); +``` + ### Exports - `HttpClient` - Main client class diff --git a/packages/core/src/http-client/http-client.test.ts b/packages/core/src/http-client/http-client.test.ts index cb7ab27..40a146c 100644 --- a/packages/core/src/http-client/http-client.test.ts +++ b/packages/core/src/http-client/http-client.test.ts @@ -302,7 +302,7 @@ describe('HttpClient', () => { ); await expect(client.get(`${baseUrl}/blocked-by-cooldown`)).rejects.toThrow( - /Rate limit exceeded for origin/, + /Rate limit exceeded for resource/, ); }); @@ -322,7 +322,7 @@ describe('HttpClient', () => { expect(result.ok).toBe(true); await expect(client.get(`${baseUrl}/cooldown-active`)).rejects.toThrow( - /Rate limit exceeded for origin/, + /Rate limit exceeded for resource/, ); }); @@ -352,7 +352,7 @@ describe('HttpClient', () => { expect(result.ok).toBe(true); await expect(client.get(`${baseUrl}/custom-cooldown`)).rejects.toThrow( - /Rate limit exceeded for origin/, + /Rate limit exceeded for resource/, ); }); @@ -907,8 +907,7 @@ describe('HttpClient', () => { remaining?: number; resetMs?: number; }; - getOriginScope: (url: string) => string; - getResource: (url: string) => string; + resolveRateLimitKey: (url: string) => string; }; expect(privateClient.normalizeHeaderNames(undefined, ['x-a'])).toEqual([ @@ -958,9 +957,11 @@ describe('HttpClient', () => { }); expect(privateClient.parseCombinedRateLimitHeader(undefined)).toEqual({}); - expect(privateClient.getOriginScope('not-a-url')).toBe('unknown'); - expect(privateClient.getResource('/still-not-a-url')).toBe('unknown'); - expect(privateClient.getResource(`${baseUrl}/`)).toBe(baseUrl); + expect(privateClient.resolveRateLimitKey('not-a-url')).toBe('unknown'); + expect(privateClient.resolveRateLimitKey('/still-not-a-url')).toBe( + 'unknown', + ); + expect(privateClient.resolveRateLimitKey(`${baseUrl}/`)).toBe(baseUrl); const privateApplyClient = client as unknown as { applyServerRateLimitHints: ( @@ -1003,7 +1004,7 @@ describe('HttpClient', () => { const privateClient = client as unknown as { serverCooldowns: Map; - getOriginScope: (url: string) => string; + resolveRateLimitKey: (url: string) => string; enforceServerCooldown: ( url: string, signal?: AbortSignal, @@ -1015,7 +1016,7 @@ describe('HttpClient', () => { ) => Promise; }; - const scope = privateClient.getOriginScope(baseUrl); + const scope = privateClient.resolveRateLimitKey(baseUrl); privateClient.serverCooldowns.set(scope, Date.now() + 1); await expect( privateClient.enforceServerCooldown(`${baseUrl}/cooldown-wait`), @@ -1127,11 +1128,11 @@ describe('HttpClient', () => { const waitingPrivate = waitingClient as unknown as { serverCooldowns: Map; - getOriginScope: (url: string) => string; + resolveRateLimitKey: (url: string) => string; enforceServerCooldown: (url: string) => Promise; }; - const scope = waitingPrivate.getOriginScope(baseUrl); + const scope = waitingPrivate.resolveRateLimitKey(baseUrl); waitingPrivate.serverCooldowns.set(scope, Date.now() + 50); await expect( @@ -3700,64 +3701,160 @@ describe('HttpClient', () => { }); }); - describe('resourceExtractor', () => { - test('overrides default origin-based resource extraction', async () => { - let recordedResource: string | undefined; + describe('resourceKeyResolver', () => { + test('uses the origin by default', () => { + const client = new HttpClient({ name: 'test' }); + const privateClient = client as unknown as { + resolveRateLimitKey: (url: string) => string; + }; + + expect(privateClient.resolveRateLimitKey(`${baseUrl}/items`)).toBe( + baseUrl, + ); + }); + + test('uses HttpClientOptions.resourceKeyResolver when provided', () => { + const client = new HttpClient({ + name: 'test', + resourceKeyResolver: (url) => `custom:${new URL(url).pathname}`, + }); + const privateClient = client as unknown as { + resolveRateLimitKey: (url: string) => string; + }; + + expect(privateClient.resolveRateLimitKey(`${baseUrl}/items`)).toBe( + 'custom:/items', + ); + }); + + test('falls back to legacy rateLimit.resourceExtractor', () => { + const client = new HttpClient({ + name: 'test', + rateLimit: { + resourceExtractor: (url) => `legacy:${new URL(url).pathname}`, + }, + }); + const privateClient = client as unknown as { + resolveRateLimitKey: (url: string) => string; + }; + + expect(privateClient.resolveRateLimitKey(`${baseUrl}/items`)).toBe( + 'legacy:/items', + ); + }); + + test('prefers resourceKeyResolver over legacy resourceExtractor', () => { + const client = new HttpClient({ + name: 'test', + resourceKeyResolver: () => 'top-level', + rateLimit: { + resourceExtractor: () => 'legacy', + }, + }); + const privateClient = client as unknown as { + resolveRateLimitKey: (url: string) => string; + }; + + expect(privateClient.resolveRateLimitKey(`${baseUrl}/items`)).toBe( + 'top-level', + ); + }); + + test('uses the custom resolver for canProceed, getWaitTime, and record', async () => { + nock(baseUrl).get('/api/issues').reply(200, { ok: true }); + + const calls = { + canProceed: [] as Array, + getWaitTime: [] as Array, + record: [] as Array, + }; + let canProceedChecks = 0; + const rateLimitStub = { async canProceed(resource: string) { - recordedResource = resource; - return true; + calls.canProceed.push(resource); + canProceedChecks += 1; + return canProceedChecks > 1; + }, + async record(resource: string) { + calls.record.push(resource); }, - async record() {}, async getStatus() { return { remaining: 1, resetTime: new Date(), limit: 60 }; }, async reset() {}, - async getWaitTime() { + async getWaitTime(resource: string) { + calls.getWaitTime.push(resource); return 0; }, } as const; const client = new HttpClient({ name: 'test', - rateLimit: { - store: rateLimitStub, - resourceExtractor: (url) => { - const u = new URL(url); - return `${u.origin}${u.pathname}`; - }, - }, + resourceKeyResolver: (url) => + new URL(url).pathname.startsWith('/api/issue/') + ? 'issues' + : new URL(url).pathname.split('/').filter(Boolean).at(-1) ?? + 'unknown', + rateLimit: { store: rateLimitStub, throw: false }, }); - nock(baseUrl).get('/items/42').reply(200, { id: 42 }); - - await client.get(`${baseUrl}/items/42`); + await client.get(`${baseUrl}/api/issues`); - expect(recordedResource).toBe(`${baseUrl}/items/42`); + expect(calls.canProceed).toEqual(['issues', 'issues']); + expect(calls.getWaitTime).toEqual(['issues']); + expect(calls.record).toEqual(['issues']); }); - test('getResource uses origin by default', () => { - const client = new HttpClient({ name: 'test' }); - const privateClient = client as unknown as { - getResource: (url: string) => string; - }; + test('normalizes retrieve and list URLs into the same rate-limit bucket', async () => { + nock(baseUrl) + .get('/api/issue/4000-123') + .reply(429, { message: 'slow down' }, { 'Retry-After': '1' }); - expect(privateClient.getResource(`${baseUrl}/items`)).toBe(baseUrl); - }); + const cooldowns = new Map(); + const rateLimitStub = { + async canProceed() { + return true; + }, + async record() {}, + async getStatus() { + return { remaining: 1, resetTime: new Date(), limit: 60 }; + }, + async reset() {}, + async getWaitTime() { + return 0; + }, + async setCooldown(resource: string, cooldownUntilMs: number) { + cooldowns.set(resource, cooldownUntilMs); + }, + async getCooldown(resource: string) { + return cooldowns.get(resource); + }, + async clearCooldown(resource: string) { + cooldowns.delete(resource); + }, + }; - test('getResource uses resourceExtractor when provided', () => { const client = new HttpClient({ name: 'test', - rateLimit: { - resourceExtractor: (url) => `custom:${new URL(url).pathname}`, + resourceKeyResolver: (url) => { + const path = new URL(url).pathname; + if (path === '/api/issues' || path.startsWith('/api/issue/')) { + return 'issues'; + } + return path.split('/').filter(Boolean).at(-1) ?? 'unknown'; }, + rateLimit: { store: rateLimitStub, throw: true }, }); - const privateClient = client as unknown as { - getResource: (url: string) => string; - }; - expect(privateClient.getResource(`${baseUrl}/items`)).toBe( - 'custom:/items', + await expect( + client.get(`${baseUrl}/api/issue/4000-123`), + ).rejects.toThrow(HttpClientError); + + expect(cooldowns.has('issues')).toBe(true); + + await expect(client.get(`${baseUrl}/api/issues`)).rejects.toThrow( + /Rate limit exceeded for resource 'issues'/, ); }); }); diff --git a/packages/core/src/http-client/http-client.ts b/packages/core/src/http-client/http-client.ts index b454f7c..8508118 100644 --- a/packages/core/src/http-client/http-client.ts +++ b/packages/core/src/http-client/http-client.ts @@ -121,8 +121,13 @@ export interface HttpClientRateLimitOptions { * Extract a rate-limit resource key from a URL. * Defaults to returning the origin (e.g. "https://api.github.com"). */ + /** @deprecated Prefer HttpClientOptions.resourceKeyResolver. */ resourceExtractor?: (url: string) => string; - /** Per-resource rate limit configs. Keys should match resourceExtractor output. */ + /** + * Per-resource rate limit configs. + * Keys should match `HttpClientOptions.resourceKeyResolver` output, or + * `resourceExtractor` output when using the legacy rate-limit option. + */ configs?: RateLimitConfigMap; /** Default rate limit config for resources not in configs. */ defaultConfig?: RateLimitConfig; @@ -140,6 +145,11 @@ export interface HttpClientOptions { dedupe?: DedupeStore; /** Rate limiting configuration and optional store. */ rateLimit?: HttpClientRateLimitOptions; + /** + * Resolve the logical rate-limit resource key for a URL. + * Defaults to returning the URL origin (e.g. "https://api.github.com"). + */ + resourceKeyResolver?: (url: string) => string; /** * Custom fetch implementation. Defaults to `globalThis.fetch`. * Use this to intercept/transform at the fetch level — e.g., resolving @@ -231,6 +241,7 @@ export class HttpClient implements HttpClientContract { retry?: HttpClientOptions['retry']; cacheOverrides?: CacheOverrideOptions; cacheScope?: string; + resourceKeyResolver?: (url: string) => string; resourceExtractor?: (url: string) => string; rateLimitConfigs?: RateLimitConfigMap; defaultRateLimitConfig?: RateLimitConfig; @@ -258,6 +269,7 @@ export class HttpClient implements HttpClientContract { cacheOverrides: options.cache?.overrides, cacheScope: options.cache && !options.cache.globalScope ? options.name : undefined, + resourceKeyResolver: options.resourceKeyResolver, resourceExtractor: options.rateLimit?.resourceExtractor, rateLimitConfigs: options.rateLimit?.configs, defaultRateLimitConfig: options.rateLimit?.defaultConfig, @@ -333,13 +345,19 @@ export class HttpClient implements HttpClientContract { } /** - * Derive the rate-limit resource key for a URL. - * Uses `resourceExtractor` when provided, otherwise defaults to the URL origin. + * Derive the rate-limit key for a URL. + * Uses `resourceKeyResolver` when provided, then the legacy + * `rateLimit.resourceExtractor`, otherwise defaults to the URL origin. */ - private getResource(url: string): string { + private resolveRateLimitKey(url: string): string { + if (this.options.resourceKeyResolver) { + return this.options.resourceKeyResolver(url); + } + if (this.options.resourceExtractor) { return this.options.resourceExtractor(url); } + try { return new URL(url).origin; } catch { @@ -429,14 +447,6 @@ export class HttpClient implements HttpClientContract { return { endpoint, params }; } - private getOriginScope(url: string): string { - try { - return new URL(url).origin; - } catch { - return 'unknown'; - } - } - private getHeaderValue( headers: Headers | Record | undefined, names: Array, @@ -583,7 +593,7 @@ export class HttpClient implements HttpClientContract { return; } - const scope = this.getOriginScope(url); + const scope = this.resolveRateLimitKey(url); const cooldownUntilMs = Date.now() + waitMs; if (this.stores.rateLimit?.setCooldown) { @@ -613,7 +623,7 @@ export class HttpClient implements HttpClientContract { signal?: AbortSignal, forceWait = false, ): Promise { - const scope = this.getOriginScope(url); + const scope = this.resolveRateLimitKey(url); const startedAt = Date.now(); // Re-check cooldown after each sleep so we never proceed while a server @@ -633,7 +643,7 @@ export class HttpClient implements HttpClientContract { if (this.options.throwOnRateLimit && !forceWait) { throw new Error( - `Rate limit exceeded for origin '${scope}'. Wait ${waitMs}ms before retrying.`, + `Rate limit exceeded for resource '${scope}'. Wait ${waitMs}ms before retrying.`, ); } @@ -642,7 +652,7 @@ export class HttpClient implements HttpClientContract { if (remainingWaitBudgetMs <= 0) { throw new Error( - `Rate limit wait exceeded maxWaitTime (${this.options.maxWaitTime}ms) for origin '${scope}'.`, + `Rate limit wait exceeded maxWaitTime (${this.options.maxWaitTime}ms) for resource '${scope}'.`, ); } @@ -1191,7 +1201,7 @@ export class HttpClient implements HttpClientContract { const { endpoint, params } = this.parseUrlForHashing(url); const rawHash = hashRequest(endpoint, params); const cacheHash = this.scopeKey(rawHash); - const resource = this.getResource(url); + const resource = this.resolveRateLimitKey(url); const cacheConfig = this.resolveCacheConfig( options.cache?.ttl, options.cache?.overrides, diff --git a/packages/core/src/stores/rate-limit-config.ts b/packages/core/src/stores/rate-limit-config.ts index 53d5953..4ffcf7a 100644 --- a/packages/core/src/stores/rate-limit-config.ts +++ b/packages/core/src/stores/rate-limit-config.ts @@ -23,6 +23,6 @@ export const DEFAULT_RATE_LIMIT: RateLimitConfig = { /** * Map of resource keys to their rate-limit configurations. - * Keys should match the output of `resourceExtractor`. + * Keys should match the output of the client's rate-limit resource key resolver. */ export type RateLimitConfigMap = Map; From 6a7ab20a4f3b8fec247abca073abd989eb55ffeb Mon Sep 17 00:00:00 2001 From: Ally Murray Date: Mon, 6 Apr 2026 13:00:55 +0100 Subject: [PATCH 2/4] Stabilize sqlite adaptive rate limit tests --- packages/core/README.md | 30 +++++++++---------- .../core/src/http-client/http-client.test.ts | 4 +-- .../sqlite-adaptive-rate-limit-store.test.ts | 16 +++++----- 3 files changed, 26 insertions(+), 24 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index 7c3e3b7..2e30b32 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -82,21 +82,21 @@ 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 | +| 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 | ### Request Flow diff --git a/packages/core/src/http-client/http-client.test.ts b/packages/core/src/http-client/http-client.test.ts index 40a146c..ebcec7c 100644 --- a/packages/core/src/http-client/http-client.test.ts +++ b/packages/core/src/http-client/http-client.test.ts @@ -3794,8 +3794,8 @@ describe('HttpClient', () => { resourceKeyResolver: (url) => new URL(url).pathname.startsWith('/api/issue/') ? 'issues' - : new URL(url).pathname.split('/').filter(Boolean).at(-1) ?? - 'unknown', + : (new URL(url).pathname.split('/').filter(Boolean).at(-1) ?? + 'unknown'), rateLimit: { store: rateLimitStub, throw: false }, }); diff --git a/packages/store-sqlite/src/sqlite-adaptive-rate-limit-store.test.ts b/packages/store-sqlite/src/sqlite-adaptive-rate-limit-store.test.ts index 1183308..42725cf 100644 --- a/packages/store-sqlite/src/sqlite-adaptive-rate-limit-store.test.ts +++ b/packages/store-sqlite/src/sqlite-adaptive-rate-limit-store.test.ts @@ -1,4 +1,5 @@ import fs from 'fs'; +import os from 'os'; import path from 'path'; import type { RateLimitConfig } from '@http-client-toolkit/core'; import Database from 'better-sqlite3'; @@ -9,14 +10,15 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); describe('SqliteAdaptiveRateLimitStore', () => { let store: SqliteAdaptiveRateLimitStore; - const testDbPath = path.join(__dirname, 'test-adaptive-rate-limit.db'); + let testDirPath: string; + let testDbPath: string; const defaultConfig: RateLimitConfig = { limit: 200, windowMs: 3600000 }; // 1 hour beforeEach(() => { - // Clean up any existing test database - if (fs.existsSync(testDbPath)) { - fs.unlinkSync(testDbPath); - } + testDirPath = fs.mkdtempSync( + path.join(os.tmpdir(), 'sqlite-adaptive-rate-limit-'), + ); + testDbPath = path.join(testDirPath, 'test-adaptive-rate-limit.db'); store = new SqliteAdaptiveRateLimitStore({ database: testDbPath, @@ -38,8 +40,8 @@ describe('SqliteAdaptiveRateLimitStore', () => { if (store) { await store.close(); } - if (fs.existsSync(testDbPath)) { - fs.unlinkSync(testDbPath); + if (testDirPath && fs.existsSync(testDirPath)) { + fs.rmSync(testDirPath, { recursive: true, force: true }); } }); From 67d015cb7cd4e47625d930a487189a60aa67bc9c Mon Sep 17 00:00:00 2001 From: Ally Murray Date: Mon, 6 Apr 2026 13:07:02 +0100 Subject: [PATCH 3/4] Document resourceExtractor deprecation --- docs-site/src/content/docs/api/http-client.mdx | 4 ++-- docs-site/src/content/docs/guides/recommended-usage.mdx | 2 +- packages/core/README.md | 3 +++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/api/http-client.mdx b/docs-site/src/content/docs/api/http-client.mdx index 9d6734c..6ef7bb8 100644 --- a/docs-site/src/content/docs/api/http-client.mdx +++ b/docs-site/src/content/docs/api/http-client.mdx @@ -54,11 +54,11 @@ new HttpClient(options) | `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 | Extract rate-limit resource key from URL | +| `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` remains supported for compatibility, but `resourceKeyResolver` takes precedence when both are provided. +`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 diff --git a/docs-site/src/content/docs/guides/recommended-usage.mdx b/docs-site/src/content/docs/guides/recommended-usage.mdx index bee818b..55afbac 100644 --- a/docs-site/src/content/docs/guides/recommended-usage.mdx +++ b/docs-site/src/content/docs/guides/recommended-usage.mdx @@ -170,7 +170,7 @@ All clients share one database and one set of stores, but cache entries are scop |---------|-------------------|---------------| | **Cache** | Private — keys prefixed with client name (e.g. `github:hash`) | Set `globalScope: true` to share cache entries across clients | | **Dedup** | Shared — uses the raw request hash with no client prefix | Dedup is always shared when clients use the same store, which is usually desirable | -| **Rate limit** | Shared by resource — tracked per URL origin, not per client | Use `resourceKeyResolver` to customise how resources are derived from URLs | +| **Rate limit** | Shared by resource — tracked per URL origin, not per client | Use `resourceKeyResolver` to customise how resources are derived from URLs. `resourceExtractor` is deprecated | ### Sharing Cache Across Clients diff --git a/packages/core/README.md b/packages/core/README.md index 2e30b32..9b8ff0f 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -177,6 +177,9 @@ const client = new HttpClient({ }); ``` +`rateLimit.resourceExtractor` is deprecated and kept temporarily for backward +compatibility. + ### Exports - `HttpClient` - Main client class From 70559407dde00a085e11250c1ab48ac4ba72a050 Mon Sep 17 00:00:00 2001 From: Ally Murray Date: Mon, 6 Apr 2026 13:19:47 +0100 Subject: [PATCH 4/4] Add changeset for resource key resolver --- .changeset/slow-poets-clap.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/slow-poets-clap.md diff --git a/.changeset/slow-poets-clap.md b/.changeset/slow-poets-clap.md new file mode 100644 index 0000000..6241cf6 --- /dev/null +++ b/.changeset/slow-poets-clap.md @@ -0,0 +1,5 @@ +--- +"@http-client-toolkit/core": minor +--- + +Add a top-level `resourceKeyResolver` option for rate-limit bucketing, deprecate legacy `rateLimit.resourceExtractor`, and preserve backward-compatible fallback behavior.