@prosopo/load-balancer 2.10.11 → 2.10.13
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 +6 -5
- package/.turbo/turbo-build$colon$tsc.log +11 -11
- package/.turbo/turbo-build.log +7 -6
- package/CHANGELOG.md +19 -0
- package/dist/cjs/index.cjs +2 -0
- package/dist/cjs/providers.cjs +29 -3
- package/dist/cjs/retry.cjs +30 -0
- package/dist/index.js +3 -1
- package/dist/providers.d.ts +6 -0
- package/dist/providers.d.ts.map +1 -1
- package/dist/providers.js +29 -3
- package/dist/providers.js.map +1 -1
- package/dist/retry.d.ts +10 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +30 -0
- package/dist/retry.js.map +1 -0
- package/dist/tests/providers.unit.test.js +48 -10
- package/dist/tests/providers.unit.test.js.map +1 -1
- package/dist/tests/retry.unit.test.d.ts +2 -0
- package/dist/tests/retry.unit.test.d.ts.map +1 -0
- package/dist/tests/retry.unit.test.js +116 -0
- package/dist/tests/retry.unit.test.js.map +1 -0
- package/package.json +2 -2
- package/src/providers.ts +54 -6
- package/src/retry.ts +77 -0
- package/src/tests/providers.unit.test.ts +64 -9
- package/src/tests/retry.unit.test.ts +151 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { getBackoffDelayMs, retryWithBackoff } from "../retry.js";
|
|
3
|
+
const noopSleep = (_ms) => Promise.resolve();
|
|
4
|
+
describe("getBackoffDelayMs", () => {
|
|
5
|
+
it("scales the delay cap exponentially with the attempt index", () => {
|
|
6
|
+
expect(getBackoffDelayMs(0, 100, 10_000, () => 1)).toBe(100);
|
|
7
|
+
expect(getBackoffDelayMs(1, 100, 10_000, () => 1)).toBe(200);
|
|
8
|
+
expect(getBackoffDelayMs(2, 100, 10_000, () => 1)).toBe(400);
|
|
9
|
+
expect(getBackoffDelayMs(3, 100, 10_000, () => 1)).toBe(800);
|
|
10
|
+
});
|
|
11
|
+
it("caps the delay at maxDelayMs even for large attempt indices", () => {
|
|
12
|
+
expect(getBackoffDelayMs(10, 100, 1_000, () => 1)).toBe(1_000);
|
|
13
|
+
expect(getBackoffDelayMs(20, 100, 1_000, () => 1)).toBe(1_000);
|
|
14
|
+
});
|
|
15
|
+
it("clamps negative or fractional attempt indices to zero", () => {
|
|
16
|
+
expect(getBackoffDelayMs(-5, 100, 10_000, () => 1)).toBe(100);
|
|
17
|
+
expect(getBackoffDelayMs(0.9, 100, 10_000, () => 1)).toBe(100);
|
|
18
|
+
});
|
|
19
|
+
it("uses full jitter — random=0 yields 0, random=1 yields the cap", () => {
|
|
20
|
+
expect(getBackoffDelayMs(2, 100, 10_000, () => 0)).toBe(0);
|
|
21
|
+
expect(getBackoffDelayMs(2, 100, 10_000, () => 0.5)).toBe(200);
|
|
22
|
+
expect(getBackoffDelayMs(2, 100, 10_000, () => 1)).toBe(400);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe("retryWithBackoff", () => {
|
|
26
|
+
it("returns the value on the first successful attempt without sleeping", async () => {
|
|
27
|
+
const sleep = vi.fn(noopSleep);
|
|
28
|
+
const fn = vi.fn(async () => "ok");
|
|
29
|
+
const result = await retryWithBackoff(fn, {
|
|
30
|
+
maxAttempts: 3,
|
|
31
|
+
baseDelayMs: 100,
|
|
32
|
+
maxDelayMs: 1_000,
|
|
33
|
+
sleep,
|
|
34
|
+
});
|
|
35
|
+
expect(result).toBe("ok");
|
|
36
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
37
|
+
expect(sleep).not.toHaveBeenCalled();
|
|
38
|
+
});
|
|
39
|
+
it("retries on failure and returns on the eventual success", async () => {
|
|
40
|
+
const sleep = vi.fn(noopSleep);
|
|
41
|
+
let call = 0;
|
|
42
|
+
const fn = vi.fn(async () => {
|
|
43
|
+
call++;
|
|
44
|
+
if (call < 3)
|
|
45
|
+
throw new Error(`transient ${call}`);
|
|
46
|
+
return "ok";
|
|
47
|
+
});
|
|
48
|
+
const result = await retryWithBackoff(fn, {
|
|
49
|
+
maxAttempts: 5,
|
|
50
|
+
baseDelayMs: 100,
|
|
51
|
+
maxDelayMs: 1_000,
|
|
52
|
+
random: () => 0.5,
|
|
53
|
+
sleep,
|
|
54
|
+
});
|
|
55
|
+
expect(result).toBe("ok");
|
|
56
|
+
expect(fn).toHaveBeenCalledTimes(3);
|
|
57
|
+
expect(sleep).toHaveBeenCalledTimes(2);
|
|
58
|
+
});
|
|
59
|
+
it("throws the final error after maxAttempts exhausted", async () => {
|
|
60
|
+
const sleep = vi.fn(noopSleep);
|
|
61
|
+
let call = 0;
|
|
62
|
+
const fn = vi.fn(async () => {
|
|
63
|
+
call++;
|
|
64
|
+
throw new Error(`fail ${call}`);
|
|
65
|
+
});
|
|
66
|
+
await expect(retryWithBackoff(fn, {
|
|
67
|
+
maxAttempts: 3,
|
|
68
|
+
baseDelayMs: 100,
|
|
69
|
+
maxDelayMs: 1_000,
|
|
70
|
+
sleep,
|
|
71
|
+
})).rejects.toThrow("fail 3");
|
|
72
|
+
expect(fn).toHaveBeenCalledTimes(3);
|
|
73
|
+
expect(sleep).toHaveBeenCalledTimes(2);
|
|
74
|
+
});
|
|
75
|
+
it("normalises non-Error throws into an Error before rethrowing", async () => {
|
|
76
|
+
const fn = vi.fn(async () => {
|
|
77
|
+
throw "string thrown";
|
|
78
|
+
});
|
|
79
|
+
await expect(retryWithBackoff(fn, {
|
|
80
|
+
maxAttempts: 2,
|
|
81
|
+
baseDelayMs: 0,
|
|
82
|
+
maxDelayMs: 0,
|
|
83
|
+
sleep: noopSleep,
|
|
84
|
+
})).rejects.toThrow("string thrown");
|
|
85
|
+
});
|
|
86
|
+
it("passes exponentially-growing delays to sleep", async () => {
|
|
87
|
+
const sleep = vi.fn(noopSleep);
|
|
88
|
+
const fn = vi.fn(async () => {
|
|
89
|
+
throw new Error("boom");
|
|
90
|
+
});
|
|
91
|
+
await expect(retryWithBackoff(fn, {
|
|
92
|
+
maxAttempts: 4,
|
|
93
|
+
baseDelayMs: 100,
|
|
94
|
+
maxDelayMs: 10_000,
|
|
95
|
+
random: () => 1,
|
|
96
|
+
sleep,
|
|
97
|
+
})).rejects.toThrow("boom");
|
|
98
|
+
const delays = sleep.mock.calls.map(([ms]) => ms);
|
|
99
|
+
expect(delays).toEqual([100, 200, 400]);
|
|
100
|
+
});
|
|
101
|
+
it("does not retry when maxAttempts is 1 (retry disabled)", async () => {
|
|
102
|
+
const sleep = vi.fn(noopSleep);
|
|
103
|
+
const fn = vi.fn(async () => {
|
|
104
|
+
throw new Error("nope");
|
|
105
|
+
});
|
|
106
|
+
await expect(retryWithBackoff(fn, {
|
|
107
|
+
maxAttempts: 1,
|
|
108
|
+
baseDelayMs: 100,
|
|
109
|
+
maxDelayMs: 1_000,
|
|
110
|
+
sleep,
|
|
111
|
+
})).rejects.toThrow("nope");
|
|
112
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
113
|
+
expect(sleep).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
//# sourceMappingURL=retry.unit.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retry.unit.test.js","sourceRoot":"","sources":["../../src/tests/retry.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.10.
|
|
3
|
+
"version": "2.10.13",
|
|
4
4
|
"description": "Provider load balancer",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"homepage": "https://github.com/prosopo/captcha#readme",
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@prosopo/common": "3.1.46",
|
|
41
|
-
"@prosopo/types": "4.9.
|
|
41
|
+
"@prosopo/types": "4.9.10",
|
|
42
42
|
"zod": "3.23.8"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
package/src/providers.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type IpMode,
|
|
19
19
|
loadBalancer,
|
|
20
20
|
} from "./balancer.js";
|
|
21
|
+
import { retryWithBackoff } from "./retry.js";
|
|
21
22
|
|
|
22
23
|
// Base DNS endpoint per env — the `pronode.prosopo.io` family is latency-routed
|
|
23
24
|
// (A/AAAA records across the pronode fleet). Clients hit this URL's `/healthz`
|
|
@@ -56,6 +57,19 @@ const cacheKey = (env: EnvironmentTypes, ipMode?: IpMode): CacheKey =>
|
|
|
56
57
|
`${env}|${ipMode ?? "dual"}`;
|
|
57
58
|
const pinPromiseCache: Map<CacheKey, Promise<string>> = new Map();
|
|
58
59
|
|
|
60
|
+
// Healthz retry policy. Healthz is a prerequisite for pinning a session to a
|
|
61
|
+
// specific pronode — without a pinned host the token embeds the load-balanced
|
|
62
|
+
// hostname, which isn't a registered on-chain provider and gets rejected at
|
|
63
|
+
// verify time. A transient blip therefore mustn't fail immediately: retry
|
|
64
|
+
// with exponential-backoff + full jitter before surfacing the error.
|
|
65
|
+
const HEALTHZ_MAX_ATTEMPTS = 3;
|
|
66
|
+
const HEALTHZ_RETRY_BASE_DELAY_MS = 250;
|
|
67
|
+
const HEALTHZ_RETRY_MAX_DELAY_MS = 2_000;
|
|
68
|
+
|
|
69
|
+
let healthzMaxAttempts = HEALTHZ_MAX_ATTEMPTS;
|
|
70
|
+
let healthzRetryBaseDelayMs = HEALTHZ_RETRY_BASE_DELAY_MS;
|
|
71
|
+
let healthzRetryMaxDelayMs = HEALTHZ_RETRY_MAX_DELAY_MS;
|
|
72
|
+
|
|
59
73
|
const fetchPinnedHost = async (baseUrl: string): Promise<string> => {
|
|
60
74
|
const res = await fetch(`${baseUrl}/healthz`, {
|
|
61
75
|
method: "GET",
|
|
@@ -72,6 +86,13 @@ const fetchPinnedHost = async (baseUrl: string): Promise<string> => {
|
|
|
72
86
|
return body.host;
|
|
73
87
|
};
|
|
74
88
|
|
|
89
|
+
const fetchPinnedHostWithRetry = (baseUrl: string): Promise<string> =>
|
|
90
|
+
retryWithBackoff(() => fetchPinnedHost(baseUrl), {
|
|
91
|
+
maxAttempts: healthzMaxAttempts,
|
|
92
|
+
baseDelayMs: healthzRetryBaseDelayMs,
|
|
93
|
+
maxDelayMs: healthzRetryMaxDelayMs,
|
|
94
|
+
});
|
|
95
|
+
|
|
75
96
|
const resolveBaseUrl = (env: EnvironmentTypes): string =>
|
|
76
97
|
DNS_ENDPOINT[env] ?? DNS_ENDPOINT.development;
|
|
77
98
|
|
|
@@ -93,18 +114,22 @@ const resolvePinnedUrl = async (
|
|
|
93
114
|
|
|
94
115
|
const promise = (async () => {
|
|
95
116
|
try {
|
|
96
|
-
const host = await
|
|
117
|
+
const host = await fetchPinnedHostWithRetry(base);
|
|
97
118
|
const parsed = new URL(base);
|
|
98
119
|
// /healthz returns the bare pronodeN.prosopo.io (env.config.host).
|
|
99
120
|
// Re-apply the ipMode label so the per-pronode URL stays on the same
|
|
100
121
|
// single-stack sub-zone (`ipv4.pronode4.prosopo.io`).
|
|
101
122
|
parsed.hostname = withIpModeLabel(host, ipMode);
|
|
102
123
|
return parsed.toString().replace(/\/$/, "");
|
|
103
|
-
} catch {
|
|
104
|
-
// Healthz
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
124
|
+
} catch (err) {
|
|
125
|
+
// Healthz is a prerequisite for the rest of the captcha flow — the
|
|
126
|
+
// token must embed a specific pronodeN URL for verify to accept it.
|
|
127
|
+
// Evict the cached rejection so a subsequent captcha attempt gets a
|
|
128
|
+
// fresh chance, and surface the error so the caller's own retry
|
|
129
|
+
// (`providerRetry` in @prosopo/procaptcha-common) can fall through
|
|
130
|
+
// to `getRandomProviderFromList`, which bypasses healthz entirely.
|
|
131
|
+
pinPromiseCache.delete(key);
|
|
132
|
+
throw err;
|
|
108
133
|
}
|
|
109
134
|
})();
|
|
110
135
|
|
|
@@ -229,3 +254,26 @@ export const _resetPinCache = () => {
|
|
|
229
254
|
export const _resetProviderListCache = () => {
|
|
230
255
|
providerListPromiseCache.clear();
|
|
231
256
|
};
|
|
257
|
+
|
|
258
|
+
// Test-only override for the healthz retry policy — tests use it to disable
|
|
259
|
+
// backoff delays (baseDelayMs: 0) and to shorten/lengthen the attempt count.
|
|
260
|
+
// Not exported from the package index — internal use only.
|
|
261
|
+
export const _setHealthzRetryPolicy = (opts: {
|
|
262
|
+
maxAttempts?: number;
|
|
263
|
+
baseDelayMs?: number;
|
|
264
|
+
maxDelayMs?: number;
|
|
265
|
+
}) => {
|
|
266
|
+
if (opts.maxAttempts !== undefined) healthzMaxAttempts = opts.maxAttempts;
|
|
267
|
+
if (opts.baseDelayMs !== undefined)
|
|
268
|
+
healthzRetryBaseDelayMs = opts.baseDelayMs;
|
|
269
|
+
if (opts.maxDelayMs !== undefined) healthzRetryMaxDelayMs = opts.maxDelayMs;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// Test-only reset for the healthz retry policy — restores the defaults so an
|
|
273
|
+
// override in one test doesn't leak into the next. Not exported from the
|
|
274
|
+
// package index — internal use only.
|
|
275
|
+
export const _resetHealthzRetryPolicy = () => {
|
|
276
|
+
healthzMaxAttempts = HEALTHZ_MAX_ATTEMPTS;
|
|
277
|
+
healthzRetryBaseDelayMs = HEALTHZ_RETRY_BASE_DELAY_MS;
|
|
278
|
+
healthzRetryMaxDelayMs = HEALTHZ_RETRY_MAX_DELAY_MS;
|
|
279
|
+
};
|
package/src/retry.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
export type RetryOptions = {
|
|
16
|
+
// Total attempts including the first. `1` disables retries.
|
|
17
|
+
maxAttempts: number;
|
|
18
|
+
// Base delay for the first retry — attempt N waits in [0, base * 2^N].
|
|
19
|
+
baseDelayMs: number;
|
|
20
|
+
// Upper bound on the jitter window so backoff can't grow unboundedly.
|
|
21
|
+
maxDelayMs: number;
|
|
22
|
+
// Injectable for deterministic tests. Defaults to Math.random.
|
|
23
|
+
random?: () => number;
|
|
24
|
+
// Injectable for deterministic tests. Defaults to setTimeout-based sleep.
|
|
25
|
+
sleep?: (ms: number) => Promise<void>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const defaultSleep = (ms: number): Promise<void> =>
|
|
29
|
+
ms > 0
|
|
30
|
+
? new Promise((resolve) => setTimeout(resolve, ms))
|
|
31
|
+
: Promise.resolve();
|
|
32
|
+
|
|
33
|
+
// Full-jitter exponential backoff — attempt N picks a uniform delay in the
|
|
34
|
+
// range [0, min(maxDelayMs, baseDelayMs * 2^N)]. Full jitter (rather than
|
|
35
|
+
// equal jitter) desynchronises clients that all failed at the same moment,
|
|
36
|
+
// so their retries don't reconverge into a thundering herd against whatever
|
|
37
|
+
// they're calling. Matches the algorithm in @prosopo/procaptcha-common's
|
|
38
|
+
// getRetryDelayMs so both retry paths behave the same way.
|
|
39
|
+
export const getBackoffDelayMs = (
|
|
40
|
+
attempt: number,
|
|
41
|
+
baseDelayMs: number,
|
|
42
|
+
maxDelayMs: number,
|
|
43
|
+
random: () => number = Math.random,
|
|
44
|
+
): number => {
|
|
45
|
+
const safeAttempt = Math.max(0, Math.floor(attempt));
|
|
46
|
+
const cap = Math.min(maxDelayMs, baseDelayMs * 2 ** safeAttempt);
|
|
47
|
+
return Math.round(random() * cap);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Run `fn` up to `maxAttempts` times, sleeping with full-jitter exponential
|
|
52
|
+
* backoff between attempts. Returns the first successful value. If every
|
|
53
|
+
* attempt throws, throws the last error (Error-normalised).
|
|
54
|
+
*/
|
|
55
|
+
export const retryWithBackoff = async <T>(
|
|
56
|
+
fn: () => Promise<T>,
|
|
57
|
+
opts: RetryOptions,
|
|
58
|
+
): Promise<T> => {
|
|
59
|
+
const {
|
|
60
|
+
maxAttempts,
|
|
61
|
+
baseDelayMs,
|
|
62
|
+
maxDelayMs,
|
|
63
|
+
random = Math.random,
|
|
64
|
+
sleep = defaultSleep,
|
|
65
|
+
} = opts;
|
|
66
|
+
let lastErr: unknown;
|
|
67
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
68
|
+
try {
|
|
69
|
+
return await fn();
|
|
70
|
+
} catch (err) {
|
|
71
|
+
lastErr = err;
|
|
72
|
+
if (attempt >= maxAttempts - 1) break;
|
|
73
|
+
await sleep(getBackoffDelayMs(attempt, baseDelayMs, maxDelayMs, random));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
77
|
+
};
|
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
16
16
|
import type { HardcodedProvider } from "../balancer.js";
|
|
17
17
|
import {
|
|
18
|
+
_resetHealthzRetryPolicy,
|
|
18
19
|
_resetPinCache,
|
|
19
20
|
_resetProviderListCache,
|
|
21
|
+
_setHealthzRetryPolicy,
|
|
20
22
|
getProviders,
|
|
21
23
|
getRandomActiveProvider,
|
|
22
24
|
getRandomProviderFromList,
|
|
@@ -45,10 +47,14 @@ const mockHealthzFetch = (host: string, ok = true, status = 200) => {
|
|
|
45
47
|
|
|
46
48
|
beforeEach(() => {
|
|
47
49
|
_resetPinCache();
|
|
50
|
+
// Keep the retry policy but drop backoff to zero so failure-path tests
|
|
51
|
+
// don't sit waiting on real timers.
|
|
52
|
+
_setHealthzRetryPolicy({ baseDelayMs: 0, maxDelayMs: 0 });
|
|
48
53
|
});
|
|
49
54
|
|
|
50
55
|
afterEach(() => {
|
|
51
56
|
globalThis.fetch = originalFetch;
|
|
57
|
+
_resetHealthzRetryPolicy();
|
|
52
58
|
});
|
|
53
59
|
|
|
54
60
|
describe("getRandomActiveProvider (dual stack)", () => {
|
|
@@ -79,13 +85,16 @@ describe("getRandomActiveProvider (dual stack)", () => {
|
|
|
79
85
|
expect(mocked).toHaveBeenCalledTimes(1);
|
|
80
86
|
});
|
|
81
87
|
|
|
82
|
-
it("
|
|
83
|
-
mockHealthzFetch("ignored", false, 503);
|
|
84
|
-
|
|
85
|
-
|
|
88
|
+
it("throws after all healthz retries are exhausted (does not fall back to LB hostname)", async () => {
|
|
89
|
+
const mocked = mockHealthzFetch("ignored", false, 503);
|
|
90
|
+
await expect(getRandomActiveProvider("production")).rejects.toThrow(
|
|
91
|
+
/healthz responded with 503/,
|
|
92
|
+
);
|
|
93
|
+
// 3 attempts total = initial + 2 retries.
|
|
94
|
+
expect(mocked).toHaveBeenCalledTimes(3);
|
|
86
95
|
});
|
|
87
96
|
|
|
88
|
-
it("
|
|
97
|
+
it("throws when the healthz body is malformed after retries", async () => {
|
|
89
98
|
const mocked = vi.fn(async () => ({
|
|
90
99
|
ok: true,
|
|
91
100
|
status: 200,
|
|
@@ -93,8 +102,53 @@ describe("getRandomActiveProvider (dual stack)", () => {
|
|
|
93
102
|
}));
|
|
94
103
|
// biome-ignore lint/suspicious/noExplicitAny: minimal Response stub for the unit test
|
|
95
104
|
globalThis.fetch = mocked as any;
|
|
105
|
+
await expect(getRandomActiveProvider("production")).rejects.toThrow(
|
|
106
|
+
/missing host field/,
|
|
107
|
+
);
|
|
108
|
+
expect(mocked).toHaveBeenCalledTimes(3);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("retries a transient healthz failure and pins on the eventual success", async () => {
|
|
112
|
+
let call = 0;
|
|
113
|
+
const mocked = vi.fn(async () => {
|
|
114
|
+
call++;
|
|
115
|
+
if (call === 1) {
|
|
116
|
+
return { ok: false, status: 503, json: async () => ({}) };
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
ok: true,
|
|
120
|
+
status: 200,
|
|
121
|
+
json: async () => ({ ok: true, host: "pronode8.prosopo.io" }),
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal Response stub for the unit test
|
|
125
|
+
globalThis.fetch = mocked as any;
|
|
96
126
|
const result = await getRandomActiveProvider("production");
|
|
97
|
-
expect(result.provider.url).toBe("https://
|
|
127
|
+
expect(result.provider.url).toBe("https://pronode8.prosopo.io");
|
|
128
|
+
expect(mocked).toHaveBeenCalledTimes(2);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("evicts a failed pin so the next captcha attempt retries healthz fresh", async () => {
|
|
132
|
+
let call = 0;
|
|
133
|
+
const mocked = vi.fn(async () => {
|
|
134
|
+
call++;
|
|
135
|
+
if (call <= 3) {
|
|
136
|
+
return { ok: false, status: 502, json: async () => ({}) };
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
ok: true,
|
|
140
|
+
status: 200,
|
|
141
|
+
json: async () => ({ ok: true, host: "pronode9.prosopo.io" }),
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal Response stub for the unit test
|
|
145
|
+
globalThis.fetch = mocked as any;
|
|
146
|
+
await expect(getRandomActiveProvider("production")).rejects.toThrow();
|
|
147
|
+
// First call failed 3 times; a fresh call must be able to retry — not
|
|
148
|
+
// return the poisoned cached rejection.
|
|
149
|
+
const result = await getRandomActiveProvider("production");
|
|
150
|
+
expect(result.provider.url).toBe("https://pronode9.prosopo.io");
|
|
151
|
+
expect(mocked).toHaveBeenCalledTimes(4);
|
|
98
152
|
});
|
|
99
153
|
});
|
|
100
154
|
|
|
@@ -121,10 +175,11 @@ describe("getRandomActiveProvider (single stack ipMode)", () => {
|
|
|
121
175
|
);
|
|
122
176
|
});
|
|
123
177
|
|
|
124
|
-
it("
|
|
178
|
+
it("throws for ipv4 mode when /healthz fails (no fallback to ipv4.pronode.prosopo.io)", async () => {
|
|
125
179
|
mockHealthzFetch("ignored", false, 500);
|
|
126
|
-
|
|
127
|
-
|
|
180
|
+
await expect(getRandomActiveProvider("production", "ipv4")).rejects.toThrow(
|
|
181
|
+
/healthz responded with 500/,
|
|
182
|
+
);
|
|
128
183
|
});
|
|
129
184
|
|
|
130
185
|
it("keeps the dual-stack cache and the ipv4 cache separate", async () => {
|
|
@@ -0,0 +1,151 @@
|
|
|
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
|
+
});
|