@prosopo/load-balancer 2.11.4 → 2.11.5
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.
- package/.turbo/turbo-build$colon$cjs.log +2 -5
- package/.turbo/turbo-build$colon$tsc.log +11 -14
- package/.turbo/turbo-build.log +3 -9
- package/CHANGELOG.md +12 -0
- package/LICENSE +201 -0
- package/package.json +56 -57
- package/src/balancer.ts +0 -225
- package/src/developmentProviderUrl.ts +0 -55
- package/src/index.ts +0 -17
- package/src/providerUrlOverride.ts +0 -44
- package/src/providers.ts +0 -352
- package/src/retry.ts +0 -82
- package/src/tests/developmentProviderUrl.unit.test.ts +0 -76
- package/src/tests/productionProviderList.unit.test.ts +0 -267
- package/src/tests/providerUrlOverride.unit.test.ts +0 -182
- package/src/tests/providers.unit.test.ts +0 -538
- package/src/tests/retry.unit.test.ts +0 -167
- package/tsconfig.cjs.json +0 -24
- package/tsconfig.json +0 -25
- package/tsconfig.tsbuildinfo +0 -1
- package/tsconfig.types.json +0 -9
|
@@ -1,538 +0,0 @@
|
|
|
1
|
-
// Copyright 2021-2026 Prosopo (UK) Ltd.
|
|
2
|
-
//
|
|
3
|
-
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
-
// you may not use this file except in compliance with the License.
|
|
5
|
-
// You may obtain a copy of the License at
|
|
6
|
-
//
|
|
7
|
-
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
-
//
|
|
9
|
-
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
-
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
-
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
-
// See the License for the specific language governing permissions and
|
|
13
|
-
// limitations under the License.
|
|
14
|
-
|
|
15
|
-
import type { EnvironmentTypes } from "@prosopo/types";
|
|
16
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
17
|
-
import type { HardcodedProvider, IpMode } from "../balancer.js";
|
|
18
|
-
import {
|
|
19
|
-
PROVIDER_LIST_MISS_RELOAD_MS,
|
|
20
|
-
PROVIDER_LIST_TTL_MS,
|
|
21
|
-
_resetHealthzRetryPolicy,
|
|
22
|
-
_resetPinCache,
|
|
23
|
-
_resetProviderListCache,
|
|
24
|
-
_setHealthzRetryPolicy,
|
|
25
|
-
findProvider,
|
|
26
|
-
getProviders,
|
|
27
|
-
getRandomActiveProvider,
|
|
28
|
-
getRandomProviderFromList,
|
|
29
|
-
} from "../providers.js";
|
|
30
|
-
|
|
31
|
-
// Mock the underlying load balancer so getProviders' caching/failure semantics
|
|
32
|
-
// can be exercised without real network calls. getRandomActiveProvider tests
|
|
33
|
-
// don't use loadBalancer, so this mock leaves them unaffected.
|
|
34
|
-
const loadBalancer = vi.fn();
|
|
35
|
-
// Defaults to "no override" so these cases exercise the normal healthz
|
|
36
|
-
// discovery path; the override's own behaviour is covered in
|
|
37
|
-
// providerUrlOverride.unit.test.ts.
|
|
38
|
-
const getProviderListOverride = vi.fn<() => HardcodedProvider[]>(() => []);
|
|
39
|
-
vi.mock("../balancer.js", () => ({
|
|
40
|
-
loadBalancer: (...args: unknown[]) => loadBalancer(...args),
|
|
41
|
-
getProviderListOverride: (...args: unknown[]) =>
|
|
42
|
-
getProviderListOverride(...(args as [])),
|
|
43
|
-
}));
|
|
44
|
-
|
|
45
|
-
const originalFetch = globalThis.fetch;
|
|
46
|
-
|
|
47
|
-
const mockHealthzFetch = (host: string, ok = true, status = 200) => {
|
|
48
|
-
const mocked = vi.fn(async () => ({
|
|
49
|
-
ok,
|
|
50
|
-
status,
|
|
51
|
-
json: async () => ({ ok: true, host }),
|
|
52
|
-
}));
|
|
53
|
-
// biome-ignore lint/suspicious/noExplicitAny: minimal Response stub for the unit test
|
|
54
|
-
globalThis.fetch = mocked as any;
|
|
55
|
-
return mocked;
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
// The provider-list cache ages off Date.now(), so drive it from the test
|
|
59
|
-
// rather than sleeping. Nothing else in this package reads the clock.
|
|
60
|
-
const originalDateNow = Date.now;
|
|
61
|
-
const CLOCK_START = 1_700_000_000_000;
|
|
62
|
-
let clock = CLOCK_START;
|
|
63
|
-
const advanceClock = (ms: number) => {
|
|
64
|
-
clock += ms;
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
beforeEach(() => {
|
|
68
|
-
_resetPinCache();
|
|
69
|
-
clock = CLOCK_START;
|
|
70
|
-
Date.now = () => clock;
|
|
71
|
-
// Keep the retry policy but drop backoff to zero so failure-path tests
|
|
72
|
-
// don't sit waiting on real timers.
|
|
73
|
-
_setHealthzRetryPolicy({ baseDelayMs: 0, maxDelayMs: 0 });
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
afterEach(() => {
|
|
77
|
-
globalThis.fetch = originalFetch;
|
|
78
|
-
Date.now = originalDateNow;
|
|
79
|
-
_resetHealthzRetryPolicy();
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
describe("getRandomActiveProvider (dual stack)", () => {
|
|
83
|
-
it("uses the local URL directly in development (no healthz round-trip)", async () => {
|
|
84
|
-
const mocked = mockHealthzFetch("ignored");
|
|
85
|
-
const result = await getRandomActiveProvider("development");
|
|
86
|
-
expect(result.provider.url).toBe("https://localhost:9229");
|
|
87
|
-
expect(mocked).not.toHaveBeenCalled();
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("pins to the host returned by /healthz in production", async () => {
|
|
91
|
-
mockHealthzFetch("pronode7.prosopo.io");
|
|
92
|
-
const result = await getRandomActiveProvider("production");
|
|
93
|
-
expect(result.provider.url).toBe("https://pronode7.prosopo.io");
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it("pins to the host returned by /healthz in staging", async () => {
|
|
97
|
-
mockHealthzFetch("staging-pronode2.prosopo.io");
|
|
98
|
-
const result = await getRandomActiveProvider("staging");
|
|
99
|
-
expect(result.provider.url).toBe("https://staging-pronode2.prosopo.io");
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it("caches the pinned URL for repeat callers in the same env + ipMode", async () => {
|
|
103
|
-
const mocked = mockHealthzFetch("pronode3.prosopo.io");
|
|
104
|
-
await getRandomActiveProvider("production");
|
|
105
|
-
await getRandomActiveProvider("production");
|
|
106
|
-
await getRandomActiveProvider("production");
|
|
107
|
-
expect(mocked).toHaveBeenCalledTimes(1);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it("throws after all healthz retries are exhausted (does not fall back to LB hostname)", async () => {
|
|
111
|
-
const mocked = mockHealthzFetch("ignored", false, 503);
|
|
112
|
-
await expect(getRandomActiveProvider("production")).rejects.toThrow(
|
|
113
|
-
/healthz responded with 503/,
|
|
114
|
-
);
|
|
115
|
-
// 3 attempts total = initial + 2 retries.
|
|
116
|
-
expect(mocked).toHaveBeenCalledTimes(3);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("throws when the healthz body is malformed after retries", async () => {
|
|
120
|
-
const mocked = vi.fn(async () => ({
|
|
121
|
-
ok: true,
|
|
122
|
-
status: 200,
|
|
123
|
-
json: async () => ({ ok: true }),
|
|
124
|
-
}));
|
|
125
|
-
// biome-ignore lint/suspicious/noExplicitAny: minimal Response stub for the unit test
|
|
126
|
-
globalThis.fetch = mocked as any;
|
|
127
|
-
await expect(getRandomActiveProvider("production")).rejects.toThrow(
|
|
128
|
-
/missing host field/,
|
|
129
|
-
);
|
|
130
|
-
expect(mocked).toHaveBeenCalledTimes(3);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it("retries a transient healthz failure and pins on the eventual success", async () => {
|
|
134
|
-
let call = 0;
|
|
135
|
-
const mocked = vi.fn(async () => {
|
|
136
|
-
call++;
|
|
137
|
-
if (call === 1) {
|
|
138
|
-
return { ok: false, status: 503, json: async () => ({}) };
|
|
139
|
-
}
|
|
140
|
-
return {
|
|
141
|
-
ok: true,
|
|
142
|
-
status: 200,
|
|
143
|
-
json: async () => ({ ok: true, host: "pronode8.prosopo.io" }),
|
|
144
|
-
};
|
|
145
|
-
});
|
|
146
|
-
// biome-ignore lint/suspicious/noExplicitAny: minimal Response stub for the unit test
|
|
147
|
-
globalThis.fetch = mocked as any;
|
|
148
|
-
const result = await getRandomActiveProvider("production");
|
|
149
|
-
expect(result.provider.url).toBe("https://pronode8.prosopo.io");
|
|
150
|
-
expect(mocked).toHaveBeenCalledTimes(2);
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it("evicts a failed pin so the next captcha attempt retries healthz fresh", async () => {
|
|
154
|
-
let call = 0;
|
|
155
|
-
const mocked = vi.fn(async () => {
|
|
156
|
-
call++;
|
|
157
|
-
if (call <= 3) {
|
|
158
|
-
return { ok: false, status: 502, json: async () => ({}) };
|
|
159
|
-
}
|
|
160
|
-
return {
|
|
161
|
-
ok: true,
|
|
162
|
-
status: 200,
|
|
163
|
-
json: async () => ({ ok: true, host: "pronode9.prosopo.io" }),
|
|
164
|
-
};
|
|
165
|
-
});
|
|
166
|
-
// biome-ignore lint/suspicious/noExplicitAny: minimal Response stub for the unit test
|
|
167
|
-
globalThis.fetch = mocked as any;
|
|
168
|
-
await expect(getRandomActiveProvider("production")).rejects.toThrow();
|
|
169
|
-
// First call failed 3 times; a fresh call must be able to retry — not
|
|
170
|
-
// return the poisoned cached rejection.
|
|
171
|
-
const result = await getRandomActiveProvider("production");
|
|
172
|
-
expect(result.provider.url).toBe("https://pronode9.prosopo.io");
|
|
173
|
-
expect(mocked).toHaveBeenCalledTimes(4);
|
|
174
|
-
});
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
describe("getRandomActiveProvider (single stack ipMode)", () => {
|
|
178
|
-
it("hits ipv4.pronode.prosopo.io/healthz and pins to ipv4.pronodeN", async () => {
|
|
179
|
-
const mocked = mockHealthzFetch("pronode4.prosopo.io");
|
|
180
|
-
const result = await getRandomActiveProvider("production", "ipv4");
|
|
181
|
-
expect(result.provider.url).toBe("https://ipv4.pronode4.prosopo.io");
|
|
182
|
-
expect(mocked).toHaveBeenCalledWith(
|
|
183
|
-
"https://ipv4.pronode.prosopo.io/healthz",
|
|
184
|
-
expect.any(Object),
|
|
185
|
-
);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it("hits ipv6 staging /healthz and pins to ipv6.staging-pronodeN", async () => {
|
|
189
|
-
const mocked = mockHealthzFetch("staging-pronode2.prosopo.io");
|
|
190
|
-
const result = await getRandomActiveProvider("staging", "ipv6");
|
|
191
|
-
expect(result.provider.url).toBe(
|
|
192
|
-
"https://ipv6.staging-pronode2.prosopo.io",
|
|
193
|
-
);
|
|
194
|
-
expect(mocked).toHaveBeenCalledWith(
|
|
195
|
-
"https://ipv6.staging.pronode.prosopo.io/healthz",
|
|
196
|
-
expect.any(Object),
|
|
197
|
-
);
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
it("throws for ipv4 mode when /healthz fails (no fallback to ipv4.pronode.prosopo.io)", async () => {
|
|
201
|
-
mockHealthzFetch("ignored", false, 500);
|
|
202
|
-
await expect(getRandomActiveProvider("production", "ipv4")).rejects.toThrow(
|
|
203
|
-
/healthz responded with 500/,
|
|
204
|
-
);
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it("keeps the dual-stack cache and the ipv4 cache separate", async () => {
|
|
208
|
-
const mocked = mockHealthzFetch("pronode9.prosopo.io");
|
|
209
|
-
await getRandomActiveProvider("production");
|
|
210
|
-
await getRandomActiveProvider("production", "ipv4");
|
|
211
|
-
await getRandomActiveProvider("production");
|
|
212
|
-
await getRandomActiveProvider("production", "ipv4");
|
|
213
|
-
// One healthz per (env, ipMode) combination.
|
|
214
|
-
expect(mocked).toHaveBeenCalledTimes(2);
|
|
215
|
-
});
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
describe("getProviders", () => {
|
|
219
|
-
beforeEach(() => {
|
|
220
|
-
_resetProviderListCache();
|
|
221
|
-
loadBalancer.mockReset();
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
it("loads the provider list once and serves repeat callers from cache", async () => {
|
|
225
|
-
const providers = [
|
|
226
|
-
{ address: "5xyz", url: "https://p1", datasetId: "0x0" },
|
|
227
|
-
];
|
|
228
|
-
loadBalancer.mockResolvedValue(providers);
|
|
229
|
-
|
|
230
|
-
const first = await getProviders("production");
|
|
231
|
-
const second = await getProviders("production");
|
|
232
|
-
|
|
233
|
-
expect(first).toBe(providers);
|
|
234
|
-
expect(second).toBe(providers);
|
|
235
|
-
expect(loadBalancer).toHaveBeenCalledTimes(1);
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
it("dedupes concurrent in-flight loads into a single call", async () => {
|
|
239
|
-
const providers = [
|
|
240
|
-
{ address: "5xyz", url: "https://p1", datasetId: "0x0" },
|
|
241
|
-
];
|
|
242
|
-
loadBalancer.mockResolvedValue(providers);
|
|
243
|
-
|
|
244
|
-
const [a, b] = await Promise.all([
|
|
245
|
-
getProviders("production"),
|
|
246
|
-
getProviders("production"),
|
|
247
|
-
]);
|
|
248
|
-
|
|
249
|
-
expect(a).toBe(providers);
|
|
250
|
-
expect(b).toBe(providers);
|
|
251
|
-
expect(loadBalancer).toHaveBeenCalledTimes(1);
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
it("does not cache failures — a rejected load is retried on the next call", async () => {
|
|
255
|
-
const providers = [
|
|
256
|
-
{ address: "5xyz", url: "https://p1", datasetId: "0x0" },
|
|
257
|
-
];
|
|
258
|
-
loadBalancer
|
|
259
|
-
.mockRejectedValueOnce(new Error("transient"))
|
|
260
|
-
.mockResolvedValueOnce(providers);
|
|
261
|
-
|
|
262
|
-
await expect(getProviders("production")).rejects.toThrow("transient");
|
|
263
|
-
// The failure must have cleared the cache, so the next call retries.
|
|
264
|
-
const retry = await getProviders("production");
|
|
265
|
-
expect(retry).toBe(providers);
|
|
266
|
-
expect(loadBalancer).toHaveBeenCalledTimes(2);
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
it("keeps a separate entry per ipMode and passes it to loadBalancer", async () => {
|
|
270
|
-
const dual: HardcodedProvider[] = [
|
|
271
|
-
{ address: "5xyz", url: "https://p1", datasetId: "0x0", weight: 1 },
|
|
272
|
-
];
|
|
273
|
-
const v4: HardcodedProvider[] = [
|
|
274
|
-
{ address: "5xyz", url: "https://ipv4.p1", datasetId: "0x0", weight: 1 },
|
|
275
|
-
];
|
|
276
|
-
loadBalancer.mockImplementation(
|
|
277
|
-
async (_env: EnvironmentTypes, ipMode?: IpMode) =>
|
|
278
|
-
ipMode === "ipv4" ? v4 : dual,
|
|
279
|
-
);
|
|
280
|
-
|
|
281
|
-
expect(await getProviders("production")).toBe(dual);
|
|
282
|
-
expect(await getProviders("production", "ipv4")).toBe(v4);
|
|
283
|
-
// Both are now cached independently.
|
|
284
|
-
expect(await getProviders("production")).toBe(dual);
|
|
285
|
-
expect(await getProviders("production", "ipv4")).toBe(v4);
|
|
286
|
-
|
|
287
|
-
expect(loadBalancer).toHaveBeenCalledTimes(2);
|
|
288
|
-
expect(loadBalancer).toHaveBeenCalledWith("production", undefined);
|
|
289
|
-
expect(loadBalancer).toHaveBeenCalledWith("production", "ipv4");
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
it("reloads once the cached list is older than the TTL", async () => {
|
|
293
|
-
const first: HardcodedProvider[] = [
|
|
294
|
-
{ address: "5A", url: "https://p1", datasetId: "0x0", weight: 1 },
|
|
295
|
-
];
|
|
296
|
-
const second: HardcodedProvider[] = [
|
|
297
|
-
{ address: "5B", url: "https://p2", datasetId: "0x0", weight: 1 },
|
|
298
|
-
];
|
|
299
|
-
loadBalancer.mockResolvedValueOnce(first).mockResolvedValueOnce(second);
|
|
300
|
-
|
|
301
|
-
expect(await getProviders("production")).toBe(first);
|
|
302
|
-
advanceClock(PROVIDER_LIST_TTL_MS - 1);
|
|
303
|
-
expect(await getProviders("production")).toBe(first);
|
|
304
|
-
advanceClock(1);
|
|
305
|
-
expect(await getProviders("production")).toBe(second);
|
|
306
|
-
expect(loadBalancer).toHaveBeenCalledTimes(2);
|
|
307
|
-
});
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
describe("findProvider", () => {
|
|
311
|
-
const providers: HardcodedProvider[] = [
|
|
312
|
-
{ address: "5A", url: "https://pronode1.io", datasetId: "0x0", weight: 1 },
|
|
313
|
-
{ address: "5B", url: "https://pronode2.io", datasetId: "0x0", weight: 1 },
|
|
314
|
-
];
|
|
315
|
-
|
|
316
|
-
beforeEach(() => {
|
|
317
|
-
_resetProviderListCache();
|
|
318
|
-
loadBalancer.mockReset();
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
it("matches the issuing provider by exact url", async () => {
|
|
322
|
-
loadBalancer.mockResolvedValue(providers);
|
|
323
|
-
|
|
324
|
-
const found = await findProvider("production", "https://pronode2.io");
|
|
325
|
-
|
|
326
|
-
expect(found?.address).toBe("5B");
|
|
327
|
-
expect(loadBalancer).toHaveBeenCalledTimes(1);
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
it("serves a hit from cache without reloading the list", async () => {
|
|
331
|
-
loadBalancer.mockResolvedValue(providers);
|
|
332
|
-
|
|
333
|
-
await findProvider("production", "https://pronode1.io");
|
|
334
|
-
await findProvider("production", "https://pronode1.io");
|
|
335
|
-
await findProvider("production", "https://pronode1.io");
|
|
336
|
-
|
|
337
|
-
expect(loadBalancer).toHaveBeenCalledTimes(1);
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
it("looks in the ipMode section the token was minted against", async () => {
|
|
341
|
-
const v4: HardcodedProvider[] = [
|
|
342
|
-
{
|
|
343
|
-
address: "5A",
|
|
344
|
-
url: "https://ipv4.pronode1.io",
|
|
345
|
-
datasetId: "0x0",
|
|
346
|
-
weight: 1,
|
|
347
|
-
},
|
|
348
|
-
];
|
|
349
|
-
loadBalancer.mockImplementation(
|
|
350
|
-
async (_env: EnvironmentTypes, ipMode?: IpMode) =>
|
|
351
|
-
ipMode === "ipv4" ? v4 : providers,
|
|
352
|
-
);
|
|
353
|
-
|
|
354
|
-
const found = await findProvider(
|
|
355
|
-
"production",
|
|
356
|
-
"https://ipv4.pronode1.io",
|
|
357
|
-
"ipv4",
|
|
358
|
-
);
|
|
359
|
-
|
|
360
|
-
expect(found?.address).toBe("5A");
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
it("reloads on a miss so a provider added mid-TTL still verifies", async () => {
|
|
364
|
-
const added: HardcodedProvider[] = [
|
|
365
|
-
...providers,
|
|
366
|
-
{
|
|
367
|
-
address: "5C",
|
|
368
|
-
url: "https://pronode3.io",
|
|
369
|
-
datasetId: "0x0",
|
|
370
|
-
weight: 1,
|
|
371
|
-
},
|
|
372
|
-
];
|
|
373
|
-
loadBalancer.mockResolvedValueOnce(providers).mockResolvedValueOnce(added);
|
|
374
|
-
|
|
375
|
-
// Warm the cache with the list that predates pronode3.
|
|
376
|
-
await findProvider("production", "https://pronode1.io");
|
|
377
|
-
advanceClock(PROVIDER_LIST_MISS_RELOAD_MS);
|
|
378
|
-
|
|
379
|
-
const found = await findProvider("production", "https://pronode3.io");
|
|
380
|
-
|
|
381
|
-
expect(found?.address).toBe("5C");
|
|
382
|
-
expect(loadBalancer).toHaveBeenCalledTimes(2);
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
it("rate-limits the reload so an unknown url can't refetch per request", async () => {
|
|
386
|
-
loadBalancer.mockResolvedValue(providers);
|
|
387
|
-
|
|
388
|
-
await findProvider("production", "https://pronode1.io");
|
|
389
|
-
advanceClock(PROVIDER_LIST_MISS_RELOAD_MS);
|
|
390
|
-
// First miss after the floor is allowed to reload...
|
|
391
|
-
expect(await findProvider("production", "https://attacker.io")).toBe(
|
|
392
|
-
undefined,
|
|
393
|
-
);
|
|
394
|
-
// ...the ones immediately behind it are not.
|
|
395
|
-
expect(await findProvider("production", "https://attacker.io")).toBe(
|
|
396
|
-
undefined,
|
|
397
|
-
);
|
|
398
|
-
expect(await findProvider("production", "https://attacker.io")).toBe(
|
|
399
|
-
undefined,
|
|
400
|
-
);
|
|
401
|
-
|
|
402
|
-
expect(loadBalancer).toHaveBeenCalledTimes(2);
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
it("returns undefined for a token with no providerUrl without loading", async () => {
|
|
406
|
-
loadBalancer.mockResolvedValue(providers);
|
|
407
|
-
|
|
408
|
-
expect(await findProvider("production", undefined)).toBe(undefined);
|
|
409
|
-
expect(loadBalancer).not.toHaveBeenCalled();
|
|
410
|
-
});
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
describe("getRandomProviderFromList", () => {
|
|
414
|
-
const providerList: HardcodedProvider[] = [
|
|
415
|
-
{
|
|
416
|
-
address: "5A",
|
|
417
|
-
url: "https://provider-a.io",
|
|
418
|
-
datasetId: "0x0",
|
|
419
|
-
weight: 1,
|
|
420
|
-
},
|
|
421
|
-
{
|
|
422
|
-
address: "5B",
|
|
423
|
-
url: "https://provider-b.io",
|
|
424
|
-
datasetId: "0x0",
|
|
425
|
-
weight: 1,
|
|
426
|
-
},
|
|
427
|
-
{
|
|
428
|
-
address: "5C",
|
|
429
|
-
url: "https://provider-c.io",
|
|
430
|
-
datasetId: "0x0",
|
|
431
|
-
weight: 1,
|
|
432
|
-
},
|
|
433
|
-
];
|
|
434
|
-
|
|
435
|
-
beforeEach(() => {
|
|
436
|
-
_resetProviderListCache();
|
|
437
|
-
_resetPinCache();
|
|
438
|
-
loadBalancer.mockReset();
|
|
439
|
-
});
|
|
440
|
-
|
|
441
|
-
it("picks a provider from the list (not the DNS-routed endpoint)", async () => {
|
|
442
|
-
loadBalancer.mockResolvedValue(providerList);
|
|
443
|
-
// random just above 1/3 lands in the second bucket (uniform weights).
|
|
444
|
-
const result = await getRandomProviderFromList(
|
|
445
|
-
"production",
|
|
446
|
-
undefined,
|
|
447
|
-
undefined,
|
|
448
|
-
() => 0.4,
|
|
449
|
-
);
|
|
450
|
-
expect(result.provider.url).toBe("https://provider-b.io");
|
|
451
|
-
expect(result.providerAccount).toBe("5B");
|
|
452
|
-
});
|
|
453
|
-
|
|
454
|
-
it("excludes the provider that just failed when others remain", async () => {
|
|
455
|
-
loadBalancer.mockResolvedValue(providerList);
|
|
456
|
-
// random=0 would normally pick the first entry; excluding provider-a
|
|
457
|
-
// shifts the pool so index 0 is provider-b.
|
|
458
|
-
const result = await getRandomProviderFromList(
|
|
459
|
-
"production",
|
|
460
|
-
undefined,
|
|
461
|
-
"https://provider-a.io",
|
|
462
|
-
() => 0,
|
|
463
|
-
);
|
|
464
|
-
expect(result.provider.url).not.toBe("https://provider-a.io");
|
|
465
|
-
expect(result.provider.url).toBe("https://provider-b.io");
|
|
466
|
-
});
|
|
467
|
-
|
|
468
|
-
it("ignores a trailing slash when matching the excluded url", async () => {
|
|
469
|
-
loadBalancer.mockResolvedValue(providerList);
|
|
470
|
-
const result = await getRandomProviderFromList(
|
|
471
|
-
"production",
|
|
472
|
-
undefined,
|
|
473
|
-
"https://provider-a.io/",
|
|
474
|
-
() => 0,
|
|
475
|
-
);
|
|
476
|
-
expect(result.provider.url).toBe("https://provider-b.io");
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
it("still returns the only provider in development even if it is excluded", async () => {
|
|
480
|
-
const single: HardcodedProvider[] = [
|
|
481
|
-
{
|
|
482
|
-
address: "5Local",
|
|
483
|
-
url: "https://localhost:9229",
|
|
484
|
-
datasetId: "0x0",
|
|
485
|
-
weight: 1,
|
|
486
|
-
},
|
|
487
|
-
];
|
|
488
|
-
loadBalancer.mockResolvedValue(single);
|
|
489
|
-
const result = await getRandomProviderFromList(
|
|
490
|
-
"development",
|
|
491
|
-
undefined,
|
|
492
|
-
"https://localhost:9229",
|
|
493
|
-
() => 0,
|
|
494
|
-
);
|
|
495
|
-
expect(result.provider.url).toBe("https://localhost:9229");
|
|
496
|
-
});
|
|
497
|
-
|
|
498
|
-
it("applies the ipv4 label to the chosen provider url", async () => {
|
|
499
|
-
loadBalancer.mockResolvedValue(providerList);
|
|
500
|
-
const result = await getRandomProviderFromList(
|
|
501
|
-
"production",
|
|
502
|
-
"ipv4",
|
|
503
|
-
undefined,
|
|
504
|
-
() => 0,
|
|
505
|
-
);
|
|
506
|
-
expect(result.provider.url).toBe("https://ipv4.provider-a.io");
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
it("weights the pick by provider weight", async () => {
|
|
510
|
-
const weighted: HardcodedProvider[] = [
|
|
511
|
-
{ address: "5A", url: "https://a.io", datasetId: "0x0", weight: 1 },
|
|
512
|
-
{ address: "5B", url: "https://b.io", datasetId: "0x0", weight: 99 },
|
|
513
|
-
];
|
|
514
|
-
loadBalancer.mockResolvedValue(weighted);
|
|
515
|
-
// threshold = 0.5 * 100 = 50 → after subtracting weight 1 (still 49),
|
|
516
|
-
// lands on the heavy second provider.
|
|
517
|
-
const result = await getRandomProviderFromList(
|
|
518
|
-
"production",
|
|
519
|
-
undefined,
|
|
520
|
-
undefined,
|
|
521
|
-
() => 0.5,
|
|
522
|
-
);
|
|
523
|
-
expect(result.provider.url).toBe("https://b.io");
|
|
524
|
-
});
|
|
525
|
-
|
|
526
|
-
it("falls back to the DNS-routed endpoint when the list is empty", async () => {
|
|
527
|
-
loadBalancer.mockResolvedValue([]);
|
|
528
|
-
const result = await getRandomProviderFromList(
|
|
529
|
-
"development",
|
|
530
|
-
undefined,
|
|
531
|
-
undefined,
|
|
532
|
-
() => 0,
|
|
533
|
-
);
|
|
534
|
-
// Development resolves the local URL without a healthz round-trip.
|
|
535
|
-
expect(result.provider.url).toBe("https://localhost:9229");
|
|
536
|
-
expect(result.providerAccount).toBe("dns-routed");
|
|
537
|
-
});
|
|
538
|
-
});
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
// Copyright 2021-2026 Prosopo (UK) Ltd.
|
|
2
|
-
//
|
|
3
|
-
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
-
// you may not use this file except in compliance with the License.
|
|
5
|
-
// You may obtain a copy of the License at
|
|
6
|
-
//
|
|
7
|
-
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
-
//
|
|
9
|
-
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
-
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
-
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
-
// See the License for the specific language governing permissions and
|
|
13
|
-
// limitations under the License.
|
|
14
|
-
|
|
15
|
-
import { describe, expect, it, vi } from "vitest";
|
|
16
|
-
import { getBackoffDelayMs, retryWithBackoff } from "../retry.js";
|
|
17
|
-
|
|
18
|
-
const noopSleep = (_ms: number): Promise<void> => Promise.resolve();
|
|
19
|
-
|
|
20
|
-
describe("getBackoffDelayMs", () => {
|
|
21
|
-
it("scales the delay cap exponentially with the attempt index", () => {
|
|
22
|
-
// random=1 → returns the full cap so the exponent is observable.
|
|
23
|
-
expect(getBackoffDelayMs(0, 100, 10_000, () => 1)).toBe(100);
|
|
24
|
-
expect(getBackoffDelayMs(1, 100, 10_000, () => 1)).toBe(200);
|
|
25
|
-
expect(getBackoffDelayMs(2, 100, 10_000, () => 1)).toBe(400);
|
|
26
|
-
expect(getBackoffDelayMs(3, 100, 10_000, () => 1)).toBe(800);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("caps the delay at maxDelayMs even for large attempt indices", () => {
|
|
30
|
-
expect(getBackoffDelayMs(10, 100, 1_000, () => 1)).toBe(1_000);
|
|
31
|
-
expect(getBackoffDelayMs(20, 100, 1_000, () => 1)).toBe(1_000);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("clamps negative or fractional attempt indices to zero", () => {
|
|
35
|
-
expect(getBackoffDelayMs(-5, 100, 10_000, () => 1)).toBe(100);
|
|
36
|
-
expect(getBackoffDelayMs(0.9, 100, 10_000, () => 1)).toBe(100);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("uses full jitter — random=0 yields 0, random=1 yields the cap", () => {
|
|
40
|
-
expect(getBackoffDelayMs(2, 100, 10_000, () => 0)).toBe(0);
|
|
41
|
-
expect(getBackoffDelayMs(2, 100, 10_000, () => 0.5)).toBe(200);
|
|
42
|
-
expect(getBackoffDelayMs(2, 100, 10_000, () => 1)).toBe(400);
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
describe("retryWithBackoff", () => {
|
|
47
|
-
it("returns the value on the first successful attempt without sleeping", async () => {
|
|
48
|
-
const sleep = vi.fn(noopSleep);
|
|
49
|
-
const fn = vi.fn(async () => "ok");
|
|
50
|
-
const result = await retryWithBackoff(fn, {
|
|
51
|
-
maxAttempts: 3,
|
|
52
|
-
baseDelayMs: 100,
|
|
53
|
-
maxDelayMs: 1_000,
|
|
54
|
-
sleep,
|
|
55
|
-
});
|
|
56
|
-
expect(result).toBe("ok");
|
|
57
|
-
expect(fn).toHaveBeenCalledTimes(1);
|
|
58
|
-
expect(sleep).not.toHaveBeenCalled();
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("retries on failure and returns on the eventual success", async () => {
|
|
62
|
-
const sleep = vi.fn(noopSleep);
|
|
63
|
-
let call = 0;
|
|
64
|
-
const fn = vi.fn(async () => {
|
|
65
|
-
call++;
|
|
66
|
-
if (call < 3) throw new Error(`transient ${call}`);
|
|
67
|
-
return "ok";
|
|
68
|
-
});
|
|
69
|
-
const result = await retryWithBackoff(fn, {
|
|
70
|
-
maxAttempts: 5,
|
|
71
|
-
baseDelayMs: 100,
|
|
72
|
-
maxDelayMs: 1_000,
|
|
73
|
-
random: () => 0.5,
|
|
74
|
-
sleep,
|
|
75
|
-
});
|
|
76
|
-
expect(result).toBe("ok");
|
|
77
|
-
expect(fn).toHaveBeenCalledTimes(3);
|
|
78
|
-
// Sleeps only between attempts, so N-1 sleeps for N-attempts-until-success.
|
|
79
|
-
expect(sleep).toHaveBeenCalledTimes(2);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("throws the final error after maxAttempts exhausted", async () => {
|
|
83
|
-
const sleep = vi.fn(noopSleep);
|
|
84
|
-
let call = 0;
|
|
85
|
-
const fn = vi.fn(async () => {
|
|
86
|
-
call++;
|
|
87
|
-
throw new Error(`fail ${call}`);
|
|
88
|
-
});
|
|
89
|
-
await expect(
|
|
90
|
-
retryWithBackoff(fn, {
|
|
91
|
-
maxAttempts: 3,
|
|
92
|
-
baseDelayMs: 100,
|
|
93
|
-
maxDelayMs: 1_000,
|
|
94
|
-
sleep,
|
|
95
|
-
}),
|
|
96
|
-
).rejects.toThrow("fail 3");
|
|
97
|
-
expect(fn).toHaveBeenCalledTimes(3);
|
|
98
|
-
// Sleeps after attempts 1 and 2, but not after the final attempt.
|
|
99
|
-
expect(sleep).toHaveBeenCalledTimes(2);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it("normalises non-Error throws into an Error before rethrowing", async () => {
|
|
103
|
-
const fn = vi.fn(async () => {
|
|
104
|
-
throw "string thrown";
|
|
105
|
-
});
|
|
106
|
-
await expect(
|
|
107
|
-
retryWithBackoff(fn, {
|
|
108
|
-
maxAttempts: 2,
|
|
109
|
-
baseDelayMs: 0,
|
|
110
|
-
maxDelayMs: 0,
|
|
111
|
-
sleep: noopSleep,
|
|
112
|
-
}),
|
|
113
|
-
).rejects.toThrow("string thrown");
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it("passes exponentially-growing delays to sleep", async () => {
|
|
117
|
-
const sleep = vi.fn(noopSleep);
|
|
118
|
-
const fn = vi.fn(async () => {
|
|
119
|
-
throw new Error("boom");
|
|
120
|
-
});
|
|
121
|
-
await expect(
|
|
122
|
-
retryWithBackoff(fn, {
|
|
123
|
-
maxAttempts: 4,
|
|
124
|
-
baseDelayMs: 100,
|
|
125
|
-
maxDelayMs: 10_000,
|
|
126
|
-
random: () => 1,
|
|
127
|
-
sleep,
|
|
128
|
-
}),
|
|
129
|
-
).rejects.toThrow("boom");
|
|
130
|
-
// random=1 → delays equal the cap: 100, 200, 400.
|
|
131
|
-
const delays = sleep.mock.calls.map(([ms]) => ms);
|
|
132
|
-
expect(delays).toEqual([100, 200, 400]);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("does not retry when maxAttempts is 1 (retry disabled)", async () => {
|
|
136
|
-
const sleep = vi.fn(noopSleep);
|
|
137
|
-
const fn = vi.fn(async () => {
|
|
138
|
-
throw new Error("nope");
|
|
139
|
-
});
|
|
140
|
-
await expect(
|
|
141
|
-
retryWithBackoff(fn, {
|
|
142
|
-
maxAttempts: 1,
|
|
143
|
-
baseDelayMs: 100,
|
|
144
|
-
maxDelayMs: 1_000,
|
|
145
|
-
sleep,
|
|
146
|
-
}),
|
|
147
|
-
).rejects.toThrow("nope");
|
|
148
|
-
expect(fn).toHaveBeenCalledTimes(1);
|
|
149
|
-
expect(sleep).not.toHaveBeenCalled();
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it.each([0, -1, 1.5, Number.NaN])(
|
|
153
|
-
"rejects maxAttempts %s without calling fn",
|
|
154
|
-
async (maxAttempts) => {
|
|
155
|
-
const fn = vi.fn(async () => "ok");
|
|
156
|
-
await expect(
|
|
157
|
-
retryWithBackoff(fn, {
|
|
158
|
-
maxAttempts,
|
|
159
|
-
baseDelayMs: 100,
|
|
160
|
-
maxDelayMs: 1_000,
|
|
161
|
-
sleep: noopSleep,
|
|
162
|
-
}),
|
|
163
|
-
).rejects.toThrow(/maxAttempts must be a positive integer/);
|
|
164
|
-
expect(fn).not.toHaveBeenCalled();
|
|
165
|
-
},
|
|
166
|
-
);
|
|
167
|
-
});
|