@prosopo/load-balancer 2.11.4 → 2.11.6
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 +21 -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,55 +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
|
-
/**
|
|
16
|
-
* Where a development widget looks for its provider.
|
|
17
|
-
*
|
|
18
|
-
* `localhost` is right when the page and the provider share a machine, and
|
|
19
|
-
* wrong the moment they do not: a phone or a simulator loading the demo over
|
|
20
|
-
* the LAN resolves `localhost` to itself, so the widget never reaches the
|
|
21
|
-
* provider and reports the site key as unregistered. The override exists so
|
|
22
|
-
* that case can be pointed at the host running the provider.
|
|
23
|
-
*
|
|
24
|
-
* Development only — staging and production resolve through their DNS-routed
|
|
25
|
-
* endpoints and ignore this entirely.
|
|
26
|
-
*/
|
|
27
|
-
export const DEFAULT_DEVELOPMENT_PROVIDER_URL = "https://localhost:9229";
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Read the override without assuming a runtime.
|
|
31
|
-
*
|
|
32
|
-
* Written as a static `process.env.X` access on purpose: the frontend build
|
|
33
|
-
* substitutes that exact expression for a literal, which a computed lookup
|
|
34
|
-
* (`process.env[key]`) would not match. For the same reason there is no
|
|
35
|
-
* `typeof process` guard — in a browser bundle the whole expression is already
|
|
36
|
-
* a string by the time it runs, and guarding on a `process` that does not
|
|
37
|
-
* exist there would discard it. Under plain node with the variable unset it is
|
|
38
|
-
* simply undefined; where neither applies the access throws and is caught.
|
|
39
|
-
*/
|
|
40
|
-
export const readDevelopmentProviderUrlOverride = (): string | undefined => {
|
|
41
|
-
try {
|
|
42
|
-
return process.env.PROSOPO_PROVIDER_URL_DEVELOPMENT;
|
|
43
|
-
} catch {
|
|
44
|
-
return undefined;
|
|
45
|
-
}
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
export const getDevelopmentProviderUrl = (
|
|
49
|
-
override: string | undefined = readDevelopmentProviderUrlOverride(),
|
|
50
|
-
): string => {
|
|
51
|
-
const trimmed = override?.trim();
|
|
52
|
-
return trimmed
|
|
53
|
-
? trimmed.replace(/\/$/, "")
|
|
54
|
-
: DEFAULT_DEVELOPMENT_PROVIDER_URL;
|
|
55
|
-
};
|
package/src/index.ts
DELETED
|
@@ -1,17 +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
|
-
export * from "./providers.js";
|
|
15
|
-
export * from "./balancer.js";
|
|
16
|
-
export * from "./developmentProviderUrl.js";
|
|
17
|
-
export * from "./providerUrlOverride.js";
|
|
@@ -1,44 +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
|
-
/**
|
|
16
|
-
* Supplies the provider list directly instead of fetching Prosopo's.
|
|
17
|
-
*
|
|
18
|
-
* A self-hosted deployment has its own providers and no hosted list to
|
|
19
|
-
* discover them from. Setting this replaces both halves of discovery — the
|
|
20
|
-
* endpoint the widget calls and the list a verifier matches a token against —
|
|
21
|
-
* with whatever it names. See `getProviderListOverride` in `balancer.ts` for
|
|
22
|
-
* the accepted forms.
|
|
23
|
-
*
|
|
24
|
-
* Distinct from `PROSOPO_PROVIDER_URL_DEVELOPMENT`, which only applies when
|
|
25
|
-
* the environment is `development`. That coupling forced self-hosters to ship
|
|
26
|
-
* a development build of the widget purely to redirect it; this override is
|
|
27
|
-
* environment-independent, so `production` stays correct everywhere.
|
|
28
|
-
*
|
|
29
|
-
* Kept in its own module, holding nothing but the read, so that parsing can
|
|
30
|
-
* live next to `convertHostedProvider` in `balancer.ts` without the two
|
|
31
|
-
* modules importing each other. A cycle here would be bundled into the widget
|
|
32
|
-
* and this package has already lost a release to one.
|
|
33
|
-
*
|
|
34
|
-
* Written as a static `process.env.X` access for the same reason as the
|
|
35
|
-
* development override: frontend bundlers substitute that exact expression
|
|
36
|
-
* for a literal, which a computed lookup would not match.
|
|
37
|
-
*/
|
|
38
|
-
export const readProviderListOverride = (): string | undefined => {
|
|
39
|
-
try {
|
|
40
|
-
return process.env.PROSOPO_PROVIDER_LIST;
|
|
41
|
-
} catch {
|
|
42
|
-
return undefined;
|
|
43
|
-
}
|
|
44
|
-
};
|
package/src/providers.ts
DELETED
|
@@ -1,352 +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, RandomProvider } from "@prosopo/types";
|
|
16
|
-
import {
|
|
17
|
-
type HardcodedProvider,
|
|
18
|
-
type IpMode,
|
|
19
|
-
getProviderListOverride,
|
|
20
|
-
loadBalancer,
|
|
21
|
-
} from "./balancer.js";
|
|
22
|
-
import { getDevelopmentProviderUrl } from "./developmentProviderUrl.js";
|
|
23
|
-
import { retryWithBackoff } from "./retry.js";
|
|
24
|
-
|
|
25
|
-
// Base DNS endpoint per env — the `pronode.prosopo.io` family is latency-routed
|
|
26
|
-
// (A/AAAA records across the pronode fleet). Clients hit this URL's `/healthz`
|
|
27
|
-
// once to discover which specific pronodeN the DNS layer picked, then pin
|
|
28
|
-
// subsequent captcha calls to that pronode so session creation and submission
|
|
29
|
-
// land on the same backend.
|
|
30
|
-
// Resolved per call rather than once at module load, so a development override
|
|
31
|
-
// set after this module is imported is still picked up.
|
|
32
|
-
const dnsEndpoints = (): Record<EnvironmentTypes, string> => ({
|
|
33
|
-
development: getDevelopmentProviderUrl(),
|
|
34
|
-
staging: "https://staging.pronode.prosopo.io",
|
|
35
|
-
production: "https://pronode.prosopo.io",
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
// Apply the `ipv4.` / `ipv6.` DNS label to a hostname. The single-stack
|
|
39
|
-
// sub-zones only resolve to A or AAAA records respectively, so this pins the
|
|
40
|
-
// network path before TLS negotiation. Ansible provisions the matching certs
|
|
41
|
-
// for both `ipv4.{global}` and `ipv4.pronodeN.{global}`.
|
|
42
|
-
const withIpModeLabel = (hostname: string, ipMode?: IpMode): string =>
|
|
43
|
-
ipMode ? `${ipMode}.${hostname}` : hostname;
|
|
44
|
-
|
|
45
|
-
const applyIpModeToUrl = (url: string, ipMode?: IpMode): string => {
|
|
46
|
-
if (!ipMode) return url;
|
|
47
|
-
try {
|
|
48
|
-
const parsed = new URL(url);
|
|
49
|
-
parsed.hostname = withIpModeLabel(parsed.hostname, ipMode);
|
|
50
|
-
return parsed.toString().replace(/\/$/, "");
|
|
51
|
-
} catch {
|
|
52
|
-
return url;
|
|
53
|
-
}
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
// Cached, in-flight pin per (env, ipMode). Keyed so dual-stack and single-stack
|
|
57
|
-
// callers maintain separate stickiness — they hit different /healthz endpoints
|
|
58
|
-
// and shouldn't share each other's resolution.
|
|
59
|
-
type CacheKey = `${EnvironmentTypes}|${IpMode | "dual"}`;
|
|
60
|
-
const cacheKey = (env: EnvironmentTypes, ipMode?: IpMode): CacheKey =>
|
|
61
|
-
`${env}|${ipMode ?? "dual"}`;
|
|
62
|
-
const pinPromiseCache: Map<CacheKey, Promise<string>> = new Map();
|
|
63
|
-
|
|
64
|
-
// Healthz retry policy. Healthz is a prerequisite for pinning a session to a
|
|
65
|
-
// specific pronode — without a pinned host the token embeds the load-balanced
|
|
66
|
-
// hostname, which isn't a registered on-chain provider and gets rejected at
|
|
67
|
-
// verify time. A transient blip therefore mustn't fail immediately: retry
|
|
68
|
-
// with exponential-backoff + full jitter before surfacing the error.
|
|
69
|
-
const HEALTHZ_MAX_ATTEMPTS = 3;
|
|
70
|
-
const HEALTHZ_RETRY_BASE_DELAY_MS = 250;
|
|
71
|
-
const HEALTHZ_RETRY_MAX_DELAY_MS = 2_000;
|
|
72
|
-
|
|
73
|
-
let healthzMaxAttempts = HEALTHZ_MAX_ATTEMPTS;
|
|
74
|
-
let healthzRetryBaseDelayMs = HEALTHZ_RETRY_BASE_DELAY_MS;
|
|
75
|
-
let healthzRetryMaxDelayMs = HEALTHZ_RETRY_MAX_DELAY_MS;
|
|
76
|
-
|
|
77
|
-
const fetchPinnedHost = async (baseUrl: string): Promise<string> => {
|
|
78
|
-
const res = await fetch(`${baseUrl}/healthz`, {
|
|
79
|
-
method: "GET",
|
|
80
|
-
cache: "no-store",
|
|
81
|
-
credentials: "omit",
|
|
82
|
-
});
|
|
83
|
-
if (!res.ok) {
|
|
84
|
-
throw new Error(`healthz responded with ${res.status}`);
|
|
85
|
-
}
|
|
86
|
-
const body = (await res.json()) as { host?: unknown };
|
|
87
|
-
if (typeof body.host !== "string" || body.host.length === 0) {
|
|
88
|
-
throw new Error("healthz response missing host field");
|
|
89
|
-
}
|
|
90
|
-
return body.host;
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
const fetchPinnedHostWithRetry = (baseUrl: string): Promise<string> =>
|
|
94
|
-
retryWithBackoff(() => fetchPinnedHost(baseUrl), {
|
|
95
|
-
maxAttempts: healthzMaxAttempts,
|
|
96
|
-
baseDelayMs: healthzRetryBaseDelayMs,
|
|
97
|
-
maxDelayMs: healthzRetryMaxDelayMs,
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
const resolveBaseUrl = (env: EnvironmentTypes): string => {
|
|
101
|
-
const endpoints = dnsEndpoints();
|
|
102
|
-
return endpoints[env] ?? endpoints.development;
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
const resolvePinnedUrl = async (
|
|
106
|
-
env: EnvironmentTypes,
|
|
107
|
-
ipMode?: IpMode,
|
|
108
|
-
): Promise<string> => {
|
|
109
|
-
// An overridden list needs no discovery: healthz exists to report which
|
|
110
|
-
// pronode the DNS layer picked, and an override already names the nodes.
|
|
111
|
-
// Returning early also keeps the widget off Prosopo's hostnames entirely.
|
|
112
|
-
// The pick is weighted and per-call rather than cached, so traffic spreads
|
|
113
|
-
// across a multi-provider override instead of every page in a browser
|
|
114
|
-
// session pinning to whichever node was drawn first.
|
|
115
|
-
const override = getProviderListOverride(undefined, ipMode);
|
|
116
|
-
if (override.length > 0) {
|
|
117
|
-
const chosen = pickWeightedProvider(override, Math.random);
|
|
118
|
-
if (chosen) return chosen.url;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// The base for /healthz already carries the ipv4./ipv6. label when one is
|
|
122
|
-
// requested, so DNS keeps the discovery request on the same single-stack
|
|
123
|
-
// path as the captcha calls that follow.
|
|
124
|
-
const base = applyIpModeToUrl(resolveBaseUrl(env), ipMode);
|
|
125
|
-
// Development has no global hostname to /healthz against; use the
|
|
126
|
-
// hardcoded local URL.
|
|
127
|
-
if (env === "development") return base;
|
|
128
|
-
|
|
129
|
-
const key = cacheKey(env, ipMode);
|
|
130
|
-
const cached = pinPromiseCache.get(key);
|
|
131
|
-
if (cached) return cached;
|
|
132
|
-
|
|
133
|
-
const promise = (async () => {
|
|
134
|
-
try {
|
|
135
|
-
const host = await fetchPinnedHostWithRetry(base);
|
|
136
|
-
const parsed = new URL(base);
|
|
137
|
-
// /healthz returns the bare pronodeN.prosopo.io (env.config.host).
|
|
138
|
-
// Re-apply the ipMode label so the per-pronode URL stays on the same
|
|
139
|
-
// single-stack sub-zone (`ipv4.pronode4.prosopo.io`).
|
|
140
|
-
parsed.hostname = withIpModeLabel(host, ipMode);
|
|
141
|
-
return parsed.toString().replace(/\/$/, "");
|
|
142
|
-
} catch (err) {
|
|
143
|
-
// Healthz is a prerequisite for the rest of the captcha flow — the
|
|
144
|
-
// token must embed a specific pronodeN URL for verify to accept it.
|
|
145
|
-
// Evict the cached rejection so a subsequent captcha attempt gets a
|
|
146
|
-
// fresh chance, and surface the error so the caller's own retry
|
|
147
|
-
// (`providerRetry` in @prosopo/procaptcha-common) can fall through
|
|
148
|
-
// to `getRandomProviderFromList`, which bypasses healthz entirely.
|
|
149
|
-
pinPromiseCache.delete(key);
|
|
150
|
-
throw err;
|
|
151
|
-
}
|
|
152
|
-
})();
|
|
153
|
-
|
|
154
|
-
pinPromiseCache.set(key, promise);
|
|
155
|
-
return promise;
|
|
156
|
-
};
|
|
157
|
-
|
|
158
|
-
// How long a loaded list is served before the next caller reloads it. The
|
|
159
|
-
// hosted list advertises `cache-control: max-age=300`, so this matches what it
|
|
160
|
-
// asks for. `findProvider` covers the window where a pronode is added mid-TTL.
|
|
161
|
-
export const PROVIDER_LIST_TTL_MS = 5 * 60 * 1000;
|
|
162
|
-
|
|
163
|
-
// Floor on how often a lookup miss may force an early reload. `providerUrl` is
|
|
164
|
-
// read from the token, which a caller controls, so an unknown url must not be
|
|
165
|
-
// able to turn every request into a fresh fetch of the list.
|
|
166
|
-
export const PROVIDER_LIST_MISS_RELOAD_MS = 10 * 1000;
|
|
167
|
-
|
|
168
|
-
interface ProviderListEntry {
|
|
169
|
-
providers: Promise<HardcodedProvider[]>;
|
|
170
|
-
loadedAt: number;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Cached, in-flight provider-list load per env + ipMode. The list rarely
|
|
174
|
-
// changes, so a single fetch is shared across callers rather than re-fetching
|
|
175
|
-
// the provider-list JSON on every verify decision. Keyed the same way as the
|
|
176
|
-
// pin cache: the `ipv4`/`ipv6` sections carry different urls to the dual-stack
|
|
177
|
-
// default, so they cannot share an entry.
|
|
178
|
-
const providerListCache: Map<CacheKey, ProviderListEntry> = new Map();
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Returns the full (cached) list of active providers for an environment.
|
|
182
|
-
* Used to look up a provider by url/address, e.g. to find the provider that
|
|
183
|
-
* issued a token before forwarding a verification request to it.
|
|
184
|
-
*/
|
|
185
|
-
export const getProviders = async (
|
|
186
|
-
env: EnvironmentTypes,
|
|
187
|
-
ipMode?: IpMode,
|
|
188
|
-
): Promise<HardcodedProvider[]> => {
|
|
189
|
-
const key = cacheKey(env, ipMode);
|
|
190
|
-
const cached = providerListCache.get(key);
|
|
191
|
-
if (cached && Date.now() - cached.loadedAt < PROVIDER_LIST_TTL_MS) {
|
|
192
|
-
return cached.providers;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const providers = loadBalancer(env, ipMode).catch((err) => {
|
|
196
|
-
// Don't cache failures — a transient fetch error shouldn't poison the
|
|
197
|
-
// cache for the lifetime of the process. Only evict this entry: a
|
|
198
|
-
// later load may already have replaced it.
|
|
199
|
-
if (providerListCache.get(key)?.providers === providers) {
|
|
200
|
-
providerListCache.delete(key);
|
|
201
|
-
}
|
|
202
|
-
throw err;
|
|
203
|
-
});
|
|
204
|
-
providerListCache.set(key, { providers, loadedAt: Date.now() });
|
|
205
|
-
return providers;
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Finds the provider that minted a token, by exact url match.
|
|
210
|
-
*
|
|
211
|
-
* Exact-string rather than normalised: the single-stack sections of the list
|
|
212
|
-
* carry sub-zone urls (`https://ipv4.pronode15.prosopo.io`), so the caller
|
|
213
|
-
* passes the `ipMode` it derived from the token's own hostname and both sides
|
|
214
|
-
* of the comparison stay in the same section.
|
|
215
|
-
*
|
|
216
|
-
* A miss reloads the list once before giving up, so a pronode added part-way
|
|
217
|
-
* through the TTL still verifies the tokens it has already minted. That reload
|
|
218
|
-
* is rate-limited, because a miss is also what an unknown url produces.
|
|
219
|
-
*/
|
|
220
|
-
export const findProvider = async (
|
|
221
|
-
env: EnvironmentTypes,
|
|
222
|
-
providerUrl: string | undefined,
|
|
223
|
-
ipMode?: IpMode,
|
|
224
|
-
): Promise<HardcodedProvider | undefined> => {
|
|
225
|
-
if (!providerUrl) return undefined;
|
|
226
|
-
|
|
227
|
-
const providers = await getProviders(env, ipMode);
|
|
228
|
-
const provider = providers.find((p) => p.url === providerUrl);
|
|
229
|
-
if (provider) return provider;
|
|
230
|
-
|
|
231
|
-
const key = cacheKey(env, ipMode);
|
|
232
|
-
const cached = providerListCache.get(key);
|
|
233
|
-
if (cached && Date.now() - cached.loadedAt < PROVIDER_LIST_MISS_RELOAD_MS) {
|
|
234
|
-
return undefined;
|
|
235
|
-
}
|
|
236
|
-
providerListCache.delete(key);
|
|
237
|
-
|
|
238
|
-
const reloaded = await getProviders(env, ipMode);
|
|
239
|
-
return reloaded.find((p) => p.url === providerUrl);
|
|
240
|
-
};
|
|
241
|
-
|
|
242
|
-
export const getRandomActiveProvider = async (
|
|
243
|
-
env: EnvironmentTypes,
|
|
244
|
-
ipMode?: IpMode,
|
|
245
|
-
): Promise<RandomProvider> => {
|
|
246
|
-
const url = await resolvePinnedUrl(env, ipMode);
|
|
247
|
-
return {
|
|
248
|
-
providerAccount: "dns-routed",
|
|
249
|
-
provider: { url },
|
|
250
|
-
};
|
|
251
|
-
};
|
|
252
|
-
|
|
253
|
-
// Compare two provider URLs ignoring a trailing slash so a stored "failed" URL
|
|
254
|
-
// still matches its list entry regardless of formatting.
|
|
255
|
-
const sameProviderUrl = (a: string, b: string): boolean =>
|
|
256
|
-
a.replace(/\/$/, "") === b.replace(/\/$/, "");
|
|
257
|
-
|
|
258
|
-
// Weighted random pick — providers advertise a capacity weight (1-100), so
|
|
259
|
-
// fallback traffic is spread in proportion to capacity rather than uniformly.
|
|
260
|
-
// Returns undefined only for an empty list (callers guard against that).
|
|
261
|
-
const pickWeightedProvider = (
|
|
262
|
-
providers: HardcodedProvider[],
|
|
263
|
-
random: () => number,
|
|
264
|
-
): HardcodedProvider | undefined => {
|
|
265
|
-
const totalWeight = providers.reduce((sum, p) => sum + p.weight, 0);
|
|
266
|
-
let threshold = random() * totalWeight;
|
|
267
|
-
let chosen: HardcodedProvider | undefined;
|
|
268
|
-
for (const provider of providers) {
|
|
269
|
-
chosen = provider;
|
|
270
|
-
threshold -= provider.weight;
|
|
271
|
-
if (threshold < 0) break;
|
|
272
|
-
}
|
|
273
|
-
return chosen;
|
|
274
|
-
};
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Pick a random provider directly from the provider list, bypassing the
|
|
278
|
-
* DNS-routed endpoint. This is the error-fallback path: once a provider has
|
|
279
|
-
* errored, retrying it re-hits the same (possibly-down) endpoint, and a fleet
|
|
280
|
-
* of widgets doing that in a tight loop can accidentally DDoS the provider — so
|
|
281
|
-
* instead we spread the retry across the fleet by choosing a random provider
|
|
282
|
-
* from the list. `excludeUrl` (the provider that just failed) is dropped from
|
|
283
|
-
* the pool when other providers remain. In development the list holds only the
|
|
284
|
-
* single local provider, so this naturally degrades to retrying that provider.
|
|
285
|
-
* `random` is injectable so tests can make the pick deterministic.
|
|
286
|
-
*/
|
|
287
|
-
export const getRandomProviderFromList = async (
|
|
288
|
-
env: EnvironmentTypes,
|
|
289
|
-
ipMode?: IpMode,
|
|
290
|
-
excludeUrl?: string,
|
|
291
|
-
random: () => number = Math.random,
|
|
292
|
-
): Promise<RandomProvider> => {
|
|
293
|
-
const providers = await getProviders(env);
|
|
294
|
-
if (providers.length === 0) {
|
|
295
|
-
// Nothing to choose from — fall back to the DNS-routed endpoint.
|
|
296
|
-
return getRandomActiveProvider(env, ipMode);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// Only exclude the failed provider when at least one other remains, so we
|
|
300
|
-
// never end up with an empty pool (e.g. a single-provider environment).
|
|
301
|
-
const eligible =
|
|
302
|
-
excludeUrl && providers.length > 1
|
|
303
|
-
? providers.filter(
|
|
304
|
-
(p) => !sameProviderUrl(applyIpModeToUrl(p.url, ipMode), excludeUrl),
|
|
305
|
-
)
|
|
306
|
-
: providers;
|
|
307
|
-
const pool = eligible.length > 0 ? eligible : providers;
|
|
308
|
-
|
|
309
|
-
const chosen = pickWeightedProvider(pool, random);
|
|
310
|
-
if (!chosen) {
|
|
311
|
-
return getRandomActiveProvider(env, ipMode);
|
|
312
|
-
}
|
|
313
|
-
return {
|
|
314
|
-
providerAccount: chosen.address,
|
|
315
|
-
provider: { url: applyIpModeToUrl(chosen.url, ipMode) },
|
|
316
|
-
};
|
|
317
|
-
};
|
|
318
|
-
|
|
319
|
-
// Test-only escape hatch so tests can isolate the healthz cache between
|
|
320
|
-
// cases. Not exported from the package index — internal use only.
|
|
321
|
-
export const _resetPinCache = () => {
|
|
322
|
-
pinPromiseCache.clear();
|
|
323
|
-
};
|
|
324
|
-
|
|
325
|
-
// Test-only escape hatch to isolate the provider-list cache between cases.
|
|
326
|
-
// Not exported from the package index — internal use only.
|
|
327
|
-
export const _resetProviderListCache = () => {
|
|
328
|
-
providerListCache.clear();
|
|
329
|
-
};
|
|
330
|
-
|
|
331
|
-
// Test-only override for the healthz retry policy — tests use it to disable
|
|
332
|
-
// backoff delays (baseDelayMs: 0) and to shorten/lengthen the attempt count.
|
|
333
|
-
// Not exported from the package index — internal use only.
|
|
334
|
-
export const _setHealthzRetryPolicy = (opts: {
|
|
335
|
-
maxAttempts?: number;
|
|
336
|
-
baseDelayMs?: number;
|
|
337
|
-
maxDelayMs?: number;
|
|
338
|
-
}) => {
|
|
339
|
-
if (opts.maxAttempts !== undefined) healthzMaxAttempts = opts.maxAttempts;
|
|
340
|
-
if (opts.baseDelayMs !== undefined)
|
|
341
|
-
healthzRetryBaseDelayMs = opts.baseDelayMs;
|
|
342
|
-
if (opts.maxDelayMs !== undefined) healthzRetryMaxDelayMs = opts.maxDelayMs;
|
|
343
|
-
};
|
|
344
|
-
|
|
345
|
-
// Test-only reset for the healthz retry policy — restores the defaults so an
|
|
346
|
-
// override in one test doesn't leak into the next. Not exported from the
|
|
347
|
-
// package index — internal use only.
|
|
348
|
-
export const _resetHealthzRetryPolicy = () => {
|
|
349
|
-
healthzMaxAttempts = HEALTHZ_MAX_ATTEMPTS;
|
|
350
|
-
healthzRetryBaseDelayMs = HEALTHZ_RETRY_BASE_DELAY_MS;
|
|
351
|
-
healthzRetryMaxDelayMs = HEALTHZ_RETRY_MAX_DELAY_MS;
|
|
352
|
-
};
|
package/src/retry.ts
DELETED
|
@@ -1,82 +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
|
-
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
|
-
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
|
|
67
|
-
throw new RangeError(
|
|
68
|
-
`maxAttempts must be a positive integer, got ${maxAttempts}`,
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
let lastErr: unknown;
|
|
72
|
-
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
73
|
-
try {
|
|
74
|
-
return await fn();
|
|
75
|
-
} catch (err) {
|
|
76
|
-
lastErr = err;
|
|
77
|
-
if (attempt >= maxAttempts - 1) break;
|
|
78
|
-
await sleep(getBackoffDelayMs(attempt, baseDelayMs, maxDelayMs, random));
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
82
|
-
};
|
|
@@ -1,76 +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 { afterEach, describe, expect, it } from "vitest";
|
|
16
|
-
import {
|
|
17
|
-
DEFAULT_DEVELOPMENT_PROVIDER_URL,
|
|
18
|
-
getDevelopmentProviderUrl,
|
|
19
|
-
readDevelopmentProviderUrlOverride,
|
|
20
|
-
} from "../developmentProviderUrl.js";
|
|
21
|
-
|
|
22
|
-
const ENV_KEY = "PROSOPO_PROVIDER_URL_DEVELOPMENT";
|
|
23
|
-
const original: string | undefined = process.env[ENV_KEY];
|
|
24
|
-
|
|
25
|
-
afterEach(() => {
|
|
26
|
-
if (original === undefined) delete process.env[ENV_KEY];
|
|
27
|
-
else process.env[ENV_KEY] = original;
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
describe("the development provider url", () => {
|
|
31
|
-
it("is localhost when nothing overrides it", () => {
|
|
32
|
-
expect(getDevelopmentProviderUrl(undefined)).toBe("https://localhost:9229");
|
|
33
|
-
expect(DEFAULT_DEVELOPMENT_PROVIDER_URL).toBe("https://localhost:9229");
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it("is the override when one is set", () => {
|
|
37
|
-
// The case this exists for: a phone loading the demo over the LAN cannot
|
|
38
|
-
// reach the provider via `localhost`, which resolves to the phone.
|
|
39
|
-
expect(getDevelopmentProviderUrl("https://192.0.2.10:9229")).toBe(
|
|
40
|
-
"https://192.0.2.10:9229",
|
|
41
|
-
);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("drops a trailing slash so the url composes with /healthz", () => {
|
|
45
|
-
expect(getDevelopmentProviderUrl("https://192.0.2.10:9229/")).toBe(
|
|
46
|
-
"https://192.0.2.10:9229",
|
|
47
|
-
);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("falls back rather than yielding an empty url", () => {
|
|
51
|
-
for (const blank of ["", " ", undefined]) {
|
|
52
|
-
expect(getDevelopmentProviderUrl(blank)).toBe(
|
|
53
|
-
DEFAULT_DEVELOPMENT_PROVIDER_URL,
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
describe("reading the override", () => {
|
|
60
|
-
it("reads the environment variable", () => {
|
|
61
|
-
process.env[ENV_KEY] = "https://198.51.100.7:9229";
|
|
62
|
-
expect(readDevelopmentProviderUrlOverride()).toBe(
|
|
63
|
-
"https://198.51.100.7:9229",
|
|
64
|
-
);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it("is undefined when the variable is unset", () => {
|
|
68
|
-
delete process.env[ENV_KEY];
|
|
69
|
-
expect(readDevelopmentProviderUrlOverride()).toBeUndefined();
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("defaults when the variable is unset", () => {
|
|
73
|
-
delete process.env[ENV_KEY];
|
|
74
|
-
expect(getDevelopmentProviderUrl()).toBe(DEFAULT_DEVELOPMENT_PROVIDER_URL);
|
|
75
|
-
});
|
|
76
|
-
});
|