@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.
@@ -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.11",
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.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 fetchPinnedHost(base);
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 unreachable / malformed fall back to the load-balanced
105
- // hostname (with the ipMode label still applied). Clients still
106
- // work, they just lose per-pronode stickiness.
107
- return base;
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("falls back to the dual-stack base URL when /healthz responds non-OK", async () => {
83
- mockHealthzFetch("ignored", false, 503);
84
- const result = await getRandomActiveProvider("production");
85
- expect(result.provider.url).toBe("https://pronode.prosopo.io");
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("falls back to the dual-stack base URL when /healthz body is malformed", async () => {
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://pronode.prosopo.io");
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("falls back to the ipv4-labelled global URL when ipv4 /healthz fails", async () => {
178
+ it("throws for ipv4 mode when /healthz fails (no fallback to ipv4.pronode.prosopo.io)", async () => {
125
179
  mockHealthzFetch("ignored", false, 500);
126
- const result = await getRandomActiveProvider("production", "ipv4");
127
- expect(result.provider.url).toBe("https://ipv4.pronode.prosopo.io");
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
+ });