@prosopo/load-balancer 2.9.20 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,224 +1,86 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
2
- import { loadBalancer } from "../index.js";
3
- import { getRandomActiveProvider, selectWeightedProvider, } from "../providers.js";
4
- import { _resetCache } from "../providers.js";
5
- vi.mock("../index.js", () => ({
6
- loadBalancer: vi.fn(),
7
- }));
8
- describe("selectWeightedProvider", () => {
9
- it("selects provider based on weight distribution", () => {
10
- const providers = [
11
- {
12
- address: "address1",
13
- url: "url1",
14
- datasetId: "dataset1",
15
- weight: 1,
16
- },
17
- {
18
- address: "address2",
19
- url: "url2",
20
- datasetId: "dataset2",
21
- weight: 3,
22
- },
23
- {
24
- address: "address3",
25
- url: "url3",
26
- datasetId: "dataset3",
27
- weight: 1,
28
- },
29
- ];
30
- expect(selectWeightedProvider(providers, 0).address).toBe("address1");
31
- expect(selectWeightedProvider(providers, 1).address).toBe("address2");
32
- expect(selectWeightedProvider(providers, 2).address).toBe("address2");
33
- expect(selectWeightedProvider(providers, 3).address).toBe("address2");
34
- expect(selectWeightedProvider(providers, 4).address).toBe("address3");
35
- expect(selectWeightedProvider(providers, 5).address).toBe("address1");
36
- expect(selectWeightedProvider(providers, 6).address).toBe("address2");
37
- });
38
- it("handles equal weights correctly", () => {
39
- const providers = [
40
- {
41
- address: "address1",
42
- url: "url1",
43
- datasetId: "dataset1",
44
- weight: 1,
45
- },
46
- {
47
- address: "address2",
48
- url: "url2",
49
- datasetId: "dataset2",
50
- weight: 1,
51
- },
52
- ];
53
- expect(selectWeightedProvider(providers, 0).address).toBe("address1");
54
- expect(selectWeightedProvider(providers, 1).address).toBe("address2");
55
- expect(selectWeightedProvider(providers, 2).address).toBe("address1");
56
- expect(selectWeightedProvider(providers, 3).address).toBe("address2");
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { _resetPinCache, getRandomActiveProvider } from "../providers.js";
3
+ const originalFetch = globalThis.fetch;
4
+ const mockHealthzFetch = (host, ok = true, status = 200) => {
5
+ const mocked = vi.fn(async () => ({
6
+ ok,
7
+ status,
8
+ json: async () => ({ ok: true, host }),
9
+ }));
10
+ globalThis.fetch = mocked;
11
+ return mocked;
12
+ };
13
+ beforeEach(() => {
14
+ _resetPinCache();
15
+ });
16
+ afterEach(() => {
17
+ globalThis.fetch = originalFetch;
18
+ });
19
+ describe("getRandomActiveProvider (dual stack)", () => {
20
+ it("uses the local URL directly in development (no healthz round-trip)", async () => {
21
+ const mocked = mockHealthzFetch("ignored");
22
+ const result = await getRandomActiveProvider("development");
23
+ expect(result.provider.url).toBe("https://localhost:9229");
24
+ expect(mocked).not.toHaveBeenCalled();
57
25
  });
58
- it("handles single provider", () => {
59
- const providers = [
60
- {
61
- address: "address1",
62
- url: "url1",
63
- datasetId: "dataset1",
64
- weight: 10,
65
- },
66
- ];
67
- expect(selectWeightedProvider(providers, 0).address).toBe("address1");
68
- expect(selectWeightedProvider(providers, 5).address).toBe("address1");
69
- expect(selectWeightedProvider(providers, 100).address).toBe("address1");
26
+ it("pins to the host returned by /healthz in production", async () => {
27
+ mockHealthzFetch("pronode7.prosopo.io");
28
+ const result = await getRandomActiveProvider("production");
29
+ expect(result.provider.url).toBe("https://pronode7.prosopo.io");
70
30
  });
71
- it("throws error for empty provider list", () => {
72
- expect(() => selectWeightedProvider([], 0)).toThrow("No providers available");
31
+ it("pins to the host returned by /healthz in staging", async () => {
32
+ mockHealthzFetch("staging-pronode2.prosopo.io");
33
+ const result = await getRandomActiveProvider("staging");
34
+ expect(result.provider.url).toBe("https://staging-pronode2.prosopo.io");
73
35
  });
74
- it("heavily weighted provider is selected more often", () => {
75
- const providers = [
76
- {
77
- address: "address1",
78
- url: "url1",
79
- datasetId: "dataset1",
80
- weight: 1,
81
- },
82
- {
83
- address: "address2",
84
- url: "url2",
85
- datasetId: "dataset2",
86
- weight: 99,
87
- },
88
- ];
89
- const selections = { address1: 0, address2: 0 };
90
- for (let i = 0; i < 100; i++) {
91
- const selected = selectWeightedProvider(providers, i);
92
- if (selected.address === "address1") {
93
- selections.address1++;
94
- }
95
- else {
96
- selections.address2++;
97
- }
98
- }
99
- expect(selections.address1).toBe(1);
100
- expect(selections.address2).toBe(99);
36
+ it("caches the pinned URL for repeat callers in the same env + ipMode", async () => {
37
+ const mocked = mockHealthzFetch("pronode3.prosopo.io");
38
+ await getRandomActiveProvider("production");
39
+ await getRandomActiveProvider("production");
40
+ await getRandomActiveProvider("production");
41
+ expect(mocked).toHaveBeenCalledTimes(1);
101
42
  });
102
- it("handles maximum weight value (100)", () => {
103
- const providers = [
104
- {
105
- address: "address1",
106
- url: "url1",
107
- datasetId: "dataset1",
108
- weight: 100,
109
- },
110
- {
111
- address: "address2",
112
- url: "url2",
113
- datasetId: "dataset2",
114
- weight: 100,
115
- },
116
- ];
117
- const selections = { address1: 0, address2: 0 };
118
- for (let i = 0; i < 200; i++) {
119
- const selected = selectWeightedProvider(providers, i);
120
- if (selected.address === "address1") {
121
- selections.address1++;
122
- }
123
- else {
124
- selections.address2++;
125
- }
126
- }
127
- expect(selections.address1).toBe(100);
128
- expect(selections.address2).toBe(100);
43
+ it("falls back to the dual-stack base URL when /healthz responds non-OK", async () => {
44
+ mockHealthzFetch("ignored", false, 503);
45
+ const result = await getRandomActiveProvider("production");
46
+ expect(result.provider.url).toBe("https://pronode.prosopo.io");
129
47
  });
130
- it("correctly handles providers without values for weight", () => {
131
- const providers = [
132
- {
133
- address: "address1",
134
- url: "url1",
135
- datasetId: "dataset1",
136
- },
137
- {
138
- address: "address2",
139
- url: "url2",
140
- datasetId: "dataset2",
141
- weight: 3,
142
- },
143
- ];
144
- const providersWithDefaults = [
145
- { ...providers[0], weight: 1 },
146
- providers[1],
147
- ];
148
- const selections = { address1: 0, address2: 0 };
149
- for (let i = 0; i < 100; i++) {
150
- const selected = selectWeightedProvider(providersWithDefaults, i);
151
- if (selected.address === "address1") {
152
- selections.address1++;
153
- }
154
- else {
155
- selections.address2++;
156
- }
157
- }
158
- expect(selections.address1).toBe(25);
159
- expect(selections.address2).toBe(75);
48
+ it("falls back to the dual-stack base URL when /healthz body is malformed", async () => {
49
+ const mocked = vi.fn(async () => ({
50
+ ok: true,
51
+ status: 200,
52
+ json: async () => ({ ok: true }),
53
+ }));
54
+ globalThis.fetch = mocked;
55
+ const result = await getRandomActiveProvider("production");
56
+ expect(result.provider.url).toBe("https://pronode.prosopo.io");
160
57
  });
161
58
  });
162
- describe("getRandomActiveProvider", () => {
163
- beforeEach(() => {
164
- _resetCache();
165
- vi.clearAllMocks();
166
- vi.resetAllMocks();
167
- vi.resetModules();
168
- });
169
- it("returns a random provider when providers list is populated", async () => {
170
- const mockProviders = [
171
- { address: "address1", url: "url1", datasetId: "dataset1", weight: 1 },
172
- { address: "address2", url: "url2", datasetId: "dataset2", weight: 1 },
173
- ];
174
- loadBalancer.mockResolvedValue(mockProviders);
175
- const result = await getRandomActiveProvider("development", 1);
176
- expect(result.providerAccount).toBe("address2");
177
- expect(result.provider.url).toBe("url2");
178
- expect(result.provider.datasetId).toBe("dataset2");
179
- });
180
- it("loads providers only once when called multiple times", async () => {
181
- const mockProviders = [
182
- { address: "address1", url: "url1", datasetId: "dataset1", weight: 1 },
183
- ];
184
- loadBalancer.mockResolvedValue(mockProviders);
185
- await getRandomActiveProvider("development", 123);
186
- await getRandomActiveProvider("development", 456);
187
- expect(loadBalancer).toHaveBeenCalledTimes(1);
59
+ describe("getRandomActiveProvider (single stack ipMode)", () => {
60
+ it("hits ipv4.pronode.prosopo.io/healthz and pins to ipv4.pronodeN", async () => {
61
+ const mocked = mockHealthzFetch("pronode4.prosopo.io");
62
+ const result = await getRandomActiveProvider("production", "ipv4");
63
+ expect(result.provider.url).toBe("https://ipv4.pronode4.prosopo.io");
64
+ expect(mocked).toHaveBeenCalledWith("https://ipv4.pronode.prosopo.io/healthz", expect.any(Object));
188
65
  });
189
- it("handles empty providers list gracefully", async () => {
190
- loadBalancer.mockResolvedValue([]);
191
- await expect(getRandomActiveProvider("development", 123)).rejects.toThrow();
66
+ it("hits ipv6 staging /healthz and pins to ipv6.staging-pronodeN", async () => {
67
+ const mocked = mockHealthzFetch("staging-pronode2.prosopo.io");
68
+ const result = await getRandomActiveProvider("staging", "ipv6");
69
+ expect(result.provider.url).toBe("https://ipv6.staging-pronode2.prosopo.io");
70
+ expect(mocked).toHaveBeenCalledWith("https://ipv6.staging.pronode.prosopo.io/healthz", expect.any(Object));
192
71
  });
193
- it("respects provider weights when selecting", async () => {
194
- const mockProviders = [
195
- { address: "address1", url: "url1", datasetId: "dataset1", weight: 1 },
196
- { address: "address2", url: "url2", datasetId: "dataset2", weight: 3 },
197
- ];
198
- loadBalancer.mockResolvedValue(mockProviders);
199
- const result0 = await getRandomActiveProvider("development", 0);
200
- expect(result0.providerAccount).toBe("address1");
201
- _resetCache();
202
- const result1 = await getRandomActiveProvider("development", 1);
203
- expect(result1.providerAccount).toBe("address2");
204
- _resetCache();
205
- const result2 = await getRandomActiveProvider("development", 2);
206
- expect(result2.providerAccount).toBe("address2");
207
- _resetCache();
208
- const result3 = await getRandomActiveProvider("development", 3);
209
- expect(result3.providerAccount).toBe("address2");
72
+ it("falls back to the ipv4-labelled global URL when ipv4 /healthz fails", async () => {
73
+ mockHealthzFetch("ignored", false, 500);
74
+ const result = await getRandomActiveProvider("production", "ipv4");
75
+ expect(result.provider.url).toBe("https://ipv4.pronode.prosopo.io");
210
76
  });
211
- it("handles providers with missing weight field (defaults to 1)", async () => {
212
- const mockProviders = [
213
- { address: "address1", url: "url1", datasetId: "dataset1", weight: 1 },
214
- { address: "address2", url: "url2", datasetId: "dataset2", weight: 1 },
215
- ];
216
- loadBalancer.mockResolvedValue(mockProviders);
217
- const result0 = await getRandomActiveProvider("development", 0);
218
- expect(result0.providerAccount).toBe("address1");
219
- _resetCache();
220
- const result1 = await getRandomActiveProvider("development", 1);
221
- expect(result1.providerAccount).toBe("address2");
77
+ it("keeps the dual-stack cache and the ipv4 cache separate", async () => {
78
+ const mocked = mockHealthzFetch("pronode9.prosopo.io");
79
+ await getRandomActiveProvider("production");
80
+ await getRandomActiveProvider("production", "ipv4");
81
+ await getRandomActiveProvider("production");
82
+ await getRandomActiveProvider("production", "ipv4");
83
+ expect(mocked).toHaveBeenCalledTimes(2);
222
84
  });
223
85
  });
224
86
  //# sourceMappingURL=providers.unit.test.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"providers.unit.test.js","sourceRoot":"","sources":["../../src/tests/providers.unit.test.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"providers.unit.test.js","sourceRoot":"","sources":["../../src/tests/providers.unit.test.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prosopo/load-balancer",
3
- "version": "2.9.20",
3
+ "version": "2.10.0",
4
4
  "description": "Provider load balancer",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -38,11 +38,11 @@
38
38
  "homepage": "https://github.com/prosopo/captcha#readme",
39
39
  "dependencies": {
40
40
  "@prosopo/common": "3.1.40",
41
- "@prosopo/types": "4.7.3",
41
+ "@prosopo/types": "4.8.0",
42
42
  "zod": "3.23.8"
43
43
  },
44
44
  "devDependencies": {
45
- "@prosopo/config": "3.3.2",
45
+ "@prosopo/config": "3.3.3",
46
46
  "@types/node": "22.10.2",
47
47
  "@vitest/coverage-v8": "3.2.4",
48
48
  "concurrently": "9.0.1",
package/src/balancer.ts CHANGED
@@ -32,17 +32,43 @@ const HardcodedProviderSchema = z.object({
32
32
 
33
33
  export type HardcodedProvider = z.infer<typeof HardcodedProviderSchema>;
34
34
 
35
+ export type IpMode = "ipv4" | "ipv6";
36
+
37
+ // Top-level keys reserved by providerListJson for the ipv4-only / ipv6-only
38
+ // sub-lists. They appear alongside the dual-stack provider entries in the
39
+ // fetched JSON; the converter skips them so existing dual-stack consumers
40
+ // keep working.
41
+ const IP_MODE_KEYS: ReadonlyArray<IpMode> = ["ipv4", "ipv6"];
42
+
35
43
  type hostedProviders = Record<string, unknown>;
36
44
 
45
+ const isProviderRecord = (value: unknown): value is hostedProviders =>
46
+ typeof value === "object" && value !== null;
47
+
37
48
  export const convertHostedProvider = (
38
49
  provider: hostedProviders,
50
+ ipMode?: IpMode,
39
51
  ): HardcodedProvider[] => {
40
- const providers = Object.values(provider).map((p) =>
41
- HardcodedProviderSchema.parse(p),
42
- );
52
+ const source =
53
+ ipMode && isProviderRecord(provider[ipMode])
54
+ ? (provider[ipMode] as hostedProviders)
55
+ : provider;
56
+
57
+ const providers = Object.entries(source)
58
+ .filter(([key]) => !IP_MODE_KEYS.includes(key as IpMode))
59
+ .map(([, value]) => HardcodedProviderSchema.parse(value));
43
60
  return providers.sort((a, b) => a.url.localeCompare(b.url));
44
61
  };
45
62
 
63
+ // Strip the leading `ipv4.` / `ipv6.` label that providerListJson prepends for
64
+ // the single-stack sub-lists so callers can derive a stable identity from the
65
+ // provider URL regardless of which ipMode was requested.
66
+ export const stripIpModeLabel = (hostname: string): string =>
67
+ hostname.replace(/^ipv[46]\./, "");
68
+
69
+ export const getProviderHostname = (provider: HardcodedProvider): string =>
70
+ stripIpModeLabel(new URL(provider.url).hostname);
71
+
46
72
  export const getLoadBalancerUrl = (environment: EnvironmentTypes): string => {
47
73
  if (environment === "production") {
48
74
  return "https://provider-list.prosopo.io/";
package/src/providers.ts CHANGED
@@ -13,119 +13,114 @@
13
13
  // limitations under the License.
14
14
 
15
15
  import type { EnvironmentTypes, RandomProvider } from "@prosopo/types";
16
- import { type HardcodedProvider, loadBalancer } from "./index.js";
16
+ import type { IpMode } from "./balancer.js";
17
17
 
18
- // Keyed by env so a prefetch and a later call with a different env don't share.
19
- const providerPromiseCache: Map<
20
- EnvironmentTypes,
21
- Promise<HardcodedProvider[]>
22
- > = new Map();
23
-
24
- /** Optional custom loader for server-side caching (e.g. cacheFile with ETag). */
25
- let customProviderLoader:
26
- | ((env: EnvironmentTypes) => Promise<HardcodedProvider[]>)
27
- | null = null;
28
-
29
- /**
30
- * Set a custom provider loader that replaces the default HTTP fetch.
31
- * Use this on the server side to inject cacheFile-based loading with
32
- * ETag/Last-Modified support for disk persistence across restarts.
33
- */
34
- export function setProviderLoader(
35
- loader: (env: EnvironmentTypes) => Promise<HardcodedProvider[]>,
36
- ): void {
37
- customProviderLoader = loader;
38
- }
18
+ // Base DNS endpoint per env the `pronode.prosopo.io` family is latency-routed
19
+ // (A/AAAA records across the pronode fleet). Clients hit this URL's `/healthz`
20
+ // once to discover which specific pronodeN the DNS layer picked, then pin
21
+ // subsequent captcha calls to that pronode so session creation and submission
22
+ // land on the same backend.
23
+ const DNS_ENDPOINT: Record<EnvironmentTypes, string> = {
24
+ development: "https://localhost:9229",
25
+ staging: "https://staging.pronode.prosopo.io",
26
+ production: "https://pronode.prosopo.io",
27
+ };
39
28
 
40
- export function _resetCache() {
41
- providerPromiseCache.clear();
42
- }
29
+ // Apply the `ipv4.` / `ipv6.` DNS label to a hostname. The single-stack
30
+ // sub-zones only resolve to A or AAAA records respectively, so this pins the
31
+ // network path before TLS negotiation. Ansible provisions the matching certs
32
+ // for both `ipv4.{global}` and `ipv4.pronodeN.{global}`.
33
+ const withIpModeLabel = (hostname: string, ipMode?: IpMode): string =>
34
+ ipMode ? `${ipMode}.${hostname}` : hostname;
43
35
 
44
- /**
45
- * Selects a weighted random provider using the entropy value.
46
- * Providers with higher weights are more likely to be selected.
47
- *
48
- * @param providers - Array of providers with weights
49
- * @param entropy - Random seed value for deterministic selection
50
- * @returns Selected provider
51
- */
52
- export function selectWeightedProvider(
53
- providers: HardcodedProvider[],
54
- entropy: number,
55
- ): HardcodedProvider {
56
- if (providers.length === 0) {
57
- throw new Error("No providers available");
36
+ const applyIpModeToUrl = (url: string, ipMode?: IpMode): string => {
37
+ if (!ipMode) return url;
38
+ try {
39
+ const parsed = new URL(url);
40
+ parsed.hostname = withIpModeLabel(parsed.hostname, ipMode);
41
+ return parsed.toString().replace(/\/$/, "");
42
+ } catch {
43
+ return url;
58
44
  }
45
+ };
59
46
 
60
- const totalWeight = providers.reduce((sum, p) => sum + p.weight, 0);
61
-
62
- // Use entropy to generate a value between 0 and totalWeight-1
63
- const randomValue = entropy % totalWeight;
64
-
65
- // Select provider based on cumulative weight
66
- let cumulativeWeight = 0;
67
- for (const provider of providers) {
68
- cumulativeWeight += provider.weight;
69
- if (randomValue < cumulativeWeight) {
70
- return provider;
71
- }
72
- }
47
+ // Cached, in-flight pin per (env, ipMode). Keyed so dual-stack and single-stack
48
+ // callers maintain separate stickiness — they hit different /healthz endpoints
49
+ // and shouldn't share each other's resolution.
50
+ type CacheKey = `${EnvironmentTypes}|${IpMode | "dual"}`;
51
+ const cacheKey = (env: EnvironmentTypes, ipMode?: IpMode): CacheKey =>
52
+ `${env}|${ipMode ?? "dual"}`;
53
+ const pinPromiseCache: Map<CacheKey, Promise<string>> = new Map();
73
54
 
74
- // Fallback (should never reach here)
75
- const selectedProvider = providers[providers.length - 1];
76
- if (!selectedProvider) {
77
- throw new Error("No providers available");
55
+ const fetchPinnedHost = async (baseUrl: string): Promise<string> => {
56
+ const res = await fetch(`${baseUrl}/healthz`, {
57
+ method: "GET",
58
+ cache: "no-store",
59
+ credentials: "omit",
60
+ });
61
+ if (!res.ok) {
62
+ throw new Error(`healthz responded with ${res.status}`);
78
63
  }
79
- return selectedProvider;
80
- }
81
-
82
- /** Load providers using the custom loader if set, otherwise the default fetch. */
83
- const loadProviders = async (
84
- env: EnvironmentTypes,
85
- ): Promise<HardcodedProvider[]> => {
86
- if (customProviderLoader) {
87
- return customProviderLoader(env);
64
+ const body = (await res.json()) as { host?: unknown };
65
+ if (typeof body.host !== "string" || body.host.length === 0) {
66
+ throw new Error("healthz response missing host field");
88
67
  }
89
- return loadBalancer(env);
68
+ return body.host;
90
69
  };
91
70
 
92
- // Caches the in-flight Promise (not the resolved array) so concurrent callers
93
- // share a single network request rather than racing.
94
- const getProvidersPromise = (
95
- env: EnvironmentTypes,
96
- ): Promise<HardcodedProvider[]> => {
97
- const existing = providerPromiseCache.get(env);
98
- if (existing) return existing;
99
- const promise = loadProviders(env).catch((err) => {
100
- providerPromiseCache.delete(env);
101
- throw err;
102
- });
103
- providerPromiseCache.set(env, promise);
104
- return promise;
105
- };
71
+ const resolveBaseUrl = (env: EnvironmentTypes): string =>
72
+ DNS_ENDPOINT[env] ?? DNS_ENDPOINT.development;
106
73
 
107
- /**
108
- * Pre-warms the provider cache for a given environment without requiring entropy.
109
- * Call this as early as possible to avoid a cold-cache delay when getRandomActiveProvider is first used.
110
- */
111
- export const prefetchProviders = async (
74
+ const resolvePinnedUrl = async (
112
75
  env: EnvironmentTypes,
113
- ): Promise<void> => {
114
- await getProvidersPromise(env);
76
+ ipMode?: IpMode,
77
+ ): Promise<string> => {
78
+ // The base for /healthz already carries the ipv4./ipv6. label when one is
79
+ // requested, so DNS keeps the discovery request on the same single-stack
80
+ // path as the captcha calls that follow.
81
+ const base = applyIpModeToUrl(resolveBaseUrl(env), ipMode);
82
+ // Development has no global hostname to /healthz against; use the
83
+ // hardcoded local URL.
84
+ if (env === "development") return base;
85
+
86
+ const key = cacheKey(env, ipMode);
87
+ const cached = pinPromiseCache.get(key);
88
+ if (cached) return cached;
89
+
90
+ const promise = (async () => {
91
+ try {
92
+ const host = await fetchPinnedHost(base);
93
+ const parsed = new URL(base);
94
+ // /healthz returns the bare pronodeN.prosopo.io (env.config.host).
95
+ // Re-apply the ipMode label so the per-pronode URL stays on the same
96
+ // single-stack sub-zone (`ipv4.pronode4.prosopo.io`).
97
+ parsed.hostname = withIpModeLabel(host, ipMode);
98
+ return parsed.toString().replace(/\/$/, "");
99
+ } catch {
100
+ // Healthz unreachable / malformed — fall back to the load-balanced
101
+ // hostname (with the ipMode label still applied). Clients still
102
+ // work, they just lose per-pronode stickiness.
103
+ return base;
104
+ }
105
+ })();
106
+
107
+ pinPromiseCache.set(key, promise);
108
+ return promise;
115
109
  };
116
110
 
117
111
  export const getRandomActiveProvider = async (
118
112
  env: EnvironmentTypes,
119
- entropy: number,
113
+ ipMode?: IpMode,
120
114
  ): Promise<RandomProvider> => {
121
- const providers = await getProvidersPromise(env);
122
- const randomProviderObj = selectWeightedProvider(providers, entropy);
123
-
115
+ const url = await resolvePinnedUrl(env, ipMode);
124
116
  return {
125
- providerAccount: randomProviderObj.address,
126
- provider: {
127
- url: randomProviderObj.url,
128
- datasetId: randomProviderObj.datasetId,
129
- },
117
+ providerAccount: "dns-routed",
118
+ provider: { url },
130
119
  };
131
120
  };
121
+
122
+ // Test-only escape hatch so tests can isolate the healthz cache between
123
+ // cases. Not exported from the package index — internal use only.
124
+ export const _resetPinCache = () => {
125
+ pinPromiseCache.clear();
126
+ };