@apifuse/provider-sdk 2.2.0-beta.8 → 2.2.0-beta.9
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/CHANGELOG.md +4 -0
- package/dist/config/loader.d.ts +68 -0
- package/dist/config/loader.js +89 -1
- package/dist/define.js +40 -9
- package/dist/runtime/choice.js +20 -2
- package/dist/runtime/http.js +112 -9
- package/dist/runtime/proxy-nodemaven.d.ts +1 -0
- package/dist/runtime/proxy-nodemaven.js +1 -1
- package/dist/runtime/state.js +12 -1
- package/dist/runtime/stealth.js +47 -16
- package/dist/server/serve.js +11 -0
- package/package.json +1 -1
- package/src/config/loader.ts +109 -0
- package/src/define.ts +45 -14
- package/src/runtime/choice.ts +25 -4
- package/src/runtime/http.ts +131 -11
- package/src/runtime/proxy-nodemaven.ts +1 -1
- package/src/runtime/state.ts +11 -1
- package/src/runtime/stealth.ts +49 -19
- package/src/server/serve.ts +11 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# @apifuse/provider-sdk Changelog
|
|
2
2
|
|
|
3
|
+
## 2.2.0-beta.9
|
|
4
|
+
|
|
5
|
+
- Release candidate for main commit 5c78c8b (bundles #67 nodemaven required-secret + #68 transport vendor-advance).
|
|
6
|
+
|
|
3
7
|
## 2.2.0-beta.8
|
|
4
8
|
|
|
5
9
|
- Release candidate for main commit 9e8a3f028ee78b9cab29d4aa3f5494ac9cffa65f.
|
package/dist/config/loader.d.ts
CHANGED
|
@@ -162,6 +162,74 @@ export declare function resolveVendorChain(policy: ProviderProxyPolicy): ProxyVe
|
|
|
162
162
|
* vendor this equals that vendor's pool size (today's behaviour).
|
|
163
163
|
*/
|
|
164
164
|
export declare function resolvePolicyProxyPoolSpan(policy: ProviderProxyPolicy): number;
|
|
165
|
+
/**
|
|
166
|
+
* Absolute upper bound on a chain's attempt span — the sum of each vendor's
|
|
167
|
+
* *maximum* pool size. Unlike `resolvePolicyProxyPoolSpan` (the configured
|
|
168
|
+
* span), this backstop is independent of `session.poolSize`, so it never
|
|
169
|
+
* truncates a legitimately large pool below the point where the flat attempt
|
|
170
|
+
* index would cross into the next vendor (e.g. a 50-slot NodeMaven pool).
|
|
171
|
+
*/
|
|
172
|
+
export declare function maxPolicyProxyPoolSpan(policy: ProviderProxyPolicy): number;
|
|
173
|
+
/**
|
|
174
|
+
* Transport-retry attempt cap for a policy-managed request. A transport failure
|
|
175
|
+
* rotates the flat attempt index onto the *next* endpoint (and, once the index
|
|
176
|
+
* passes the primary vendor's pool span, the *next vendor*), so the cap must be
|
|
177
|
+
* the chain's full pool span for failover to reach the fallback vendor — the
|
|
178
|
+
* per-endpoint retry budget (default 3) never gets there.
|
|
179
|
+
*
|
|
180
|
+
* The span only widens beyond the caller's retry budget when ALL hold:
|
|
181
|
+
* - the request is policy-allocator managed (not a caller-supplied proxy URL);
|
|
182
|
+
* - the caller did NOT pin an explicit retry policy — `HttpRetryOptions.attempts`
|
|
183
|
+
* is the documented total-attempt ceiling and must be honoured verbatim;
|
|
184
|
+
* - the method is safe/idempotent — an unsafe request must never be duplicated
|
|
185
|
+
* across the pool even if some framework default would allow it;
|
|
186
|
+
* - the policy resolves a non-empty *registry* vendor chain (smartproxy /
|
|
187
|
+
* nodemaven). Static vendors (custom / decodo) and credential-less policies
|
|
188
|
+
* resolve no allocator pool, so every attempt would hit the same endpoint
|
|
189
|
+
* with no possible crossover — they keep the retry budget.
|
|
190
|
+
*
|
|
191
|
+
* The widened cap is bounded by the chain's true maximum span (sum of each
|
|
192
|
+
* vendor's max pool size), so a large NodeMaven pool (≤50) stays reachable and
|
|
193
|
+
* a pathological chain can never spin unbounded.
|
|
194
|
+
*/
|
|
195
|
+
/**
|
|
196
|
+
* True when a policy request is in *implicit chain-rotation* mode: successive
|
|
197
|
+
* transport attempts rotate the flat index across the concatenated vendor pool
|
|
198
|
+
* spans (and, past the primary vendor's span, into the fallback vendor). This is
|
|
199
|
+
* the ONLY mode in which the transport loop widens its attempt cap AND
|
|
200
|
+
* de-duplicates repeated endpoints — the two behaviours must share one predicate
|
|
201
|
+
* so they never diverge. It holds when ALL of the widening conditions hold:
|
|
202
|
+
* - the request is policy-allocator managed (not a caller-supplied proxy URL);
|
|
203
|
+
* - the caller did NOT pin an explicit retry policy — its `attempts` ceiling is
|
|
204
|
+
* the documented contract and must be honoured verbatim against whatever
|
|
205
|
+
* endpoint each attempt resolves (even a repeated one), so no de-duplication;
|
|
206
|
+
* - the method is safe/idempotent — an unsafe request is never duplicated;
|
|
207
|
+
* - the policy resolves a non-empty registry vendor chain (smartproxy /
|
|
208
|
+
* nodemaven). Static vendors (custom / decodo) resolve the same URL every
|
|
209
|
+
* attempt, so there is nothing to rotate or de-duplicate.
|
|
210
|
+
*/
|
|
211
|
+
export declare function policyRotatesTransportVendorChain(input: {
|
|
212
|
+
policy: ProviderProxyPolicy | undefined;
|
|
213
|
+
usesPolicyAllocator: boolean;
|
|
214
|
+
explicitRetry: boolean;
|
|
215
|
+
method: string;
|
|
216
|
+
}): boolean;
|
|
217
|
+
export declare function resolvePolicyTransportAttemptCap(input: {
|
|
218
|
+
policy: ProviderProxyPolicy | undefined;
|
|
219
|
+
usesPolicyAllocator: boolean;
|
|
220
|
+
retryAttempts: number;
|
|
221
|
+
explicitRetry: boolean;
|
|
222
|
+
method: string;
|
|
223
|
+
}): number;
|
|
224
|
+
/**
|
|
225
|
+
* A registry vendor chain (smartproxy/nodemaven) resolves a potentially
|
|
226
|
+
* *different* endpoint per flat attempt index, so a transport retry should
|
|
227
|
+
* advance across endpoints and de-duplicate once the chain stops yielding new
|
|
228
|
+
* ones. Static/custom/decodo policies (empty registry chain) resolve the *same*
|
|
229
|
+
* URL every attempt by design — retrying that same endpoint is intended, so the
|
|
230
|
+
* transport loop must not de-duplicate them.
|
|
231
|
+
*/
|
|
232
|
+
export declare function policyResolvesRegistryVendorChain(policy: ProviderProxyPolicy | undefined): boolean;
|
|
165
233
|
/** Map a resolved proxy source label to the vendor that served it. */
|
|
166
234
|
export declare function vendorFromResolvedSource(source: ResolvedProxyConfig["source"]): ProxyVendorName | undefined;
|
|
167
235
|
/**
|
package/dist/config/loader.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { Redis } from "ioredis";
|
|
5
|
-
import { NODEMAVEN_DEFAULT_PROTOCOL, hasNodemavenCredentials, nodemavenPoolSize, synthesizeNodemavenProxy, } from "../runtime/proxy-nodemaven.js";
|
|
5
|
+
import { NODEMAVEN_DEFAULT_PROTOCOL, NODEMAVEN_MAX_POOL_SIZE, hasNodemavenCredentials, nodemavenPoolSize, synthesizeNodemavenProxy, } from "../runtime/proxy-nodemaven.js";
|
|
6
6
|
// "smartproxy" here is api.smartproxy.org — a residential proxy with an IP
|
|
7
7
|
// extraction API (app_key → raw ip:port pool). It is NOT the company formerly
|
|
8
8
|
// named Smartproxy (smartproxy.com), which rebranded to Decodo in 2025 and is
|
|
@@ -541,6 +541,94 @@ export function resolvePolicyProxyPoolSpan(policy) {
|
|
|
541
541
|
return resolveSmartproxyPoolSize(policy);
|
|
542
542
|
return chain.reduce((sum, vendor) => sum + vendorPoolSize(vendor, policy), 0);
|
|
543
543
|
}
|
|
544
|
+
function vendorMaxPoolSize(vendor) {
|
|
545
|
+
return vendor === "nodemaven" ? NODEMAVEN_MAX_POOL_SIZE : SMARTPROXY_MAX_POOL_SIZE;
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Absolute upper bound on a chain's attempt span — the sum of each vendor's
|
|
549
|
+
* *maximum* pool size. Unlike `resolvePolicyProxyPoolSpan` (the configured
|
|
550
|
+
* span), this backstop is independent of `session.poolSize`, so it never
|
|
551
|
+
* truncates a legitimately large pool below the point where the flat attempt
|
|
552
|
+
* index would cross into the next vendor (e.g. a 50-slot NodeMaven pool).
|
|
553
|
+
*/
|
|
554
|
+
export function maxPolicyProxyPoolSpan(policy) {
|
|
555
|
+
const chain = resolveVendorChain(policy);
|
|
556
|
+
if (chain.length === 0)
|
|
557
|
+
return SMARTPROXY_MAX_POOL_SIZE;
|
|
558
|
+
return chain.reduce((sum, vendor) => sum + vendorMaxPoolSize(vendor), 0);
|
|
559
|
+
}
|
|
560
|
+
const UNSAFE_TRANSPORT_RETRY_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE", "TRACE"]);
|
|
561
|
+
/**
|
|
562
|
+
* Transport-retry attempt cap for a policy-managed request. A transport failure
|
|
563
|
+
* rotates the flat attempt index onto the *next* endpoint (and, once the index
|
|
564
|
+
* passes the primary vendor's pool span, the *next vendor*), so the cap must be
|
|
565
|
+
* the chain's full pool span for failover to reach the fallback vendor — the
|
|
566
|
+
* per-endpoint retry budget (default 3) never gets there.
|
|
567
|
+
*
|
|
568
|
+
* The span only widens beyond the caller's retry budget when ALL hold:
|
|
569
|
+
* - the request is policy-allocator managed (not a caller-supplied proxy URL);
|
|
570
|
+
* - the caller did NOT pin an explicit retry policy — `HttpRetryOptions.attempts`
|
|
571
|
+
* is the documented total-attempt ceiling and must be honoured verbatim;
|
|
572
|
+
* - the method is safe/idempotent — an unsafe request must never be duplicated
|
|
573
|
+
* across the pool even if some framework default would allow it;
|
|
574
|
+
* - the policy resolves a non-empty *registry* vendor chain (smartproxy /
|
|
575
|
+
* nodemaven). Static vendors (custom / decodo) and credential-less policies
|
|
576
|
+
* resolve no allocator pool, so every attempt would hit the same endpoint
|
|
577
|
+
* with no possible crossover — they keep the retry budget.
|
|
578
|
+
*
|
|
579
|
+
* The widened cap is bounded by the chain's true maximum span (sum of each
|
|
580
|
+
* vendor's max pool size), so a large NodeMaven pool (≤50) stays reachable and
|
|
581
|
+
* a pathological chain can never spin unbounded.
|
|
582
|
+
*/
|
|
583
|
+
/**
|
|
584
|
+
* True when a policy request is in *implicit chain-rotation* mode: successive
|
|
585
|
+
* transport attempts rotate the flat index across the concatenated vendor pool
|
|
586
|
+
* spans (and, past the primary vendor's span, into the fallback vendor). This is
|
|
587
|
+
* the ONLY mode in which the transport loop widens its attempt cap AND
|
|
588
|
+
* de-duplicates repeated endpoints — the two behaviours must share one predicate
|
|
589
|
+
* so they never diverge. It holds when ALL of the widening conditions hold:
|
|
590
|
+
* - the request is policy-allocator managed (not a caller-supplied proxy URL);
|
|
591
|
+
* - the caller did NOT pin an explicit retry policy — its `attempts` ceiling is
|
|
592
|
+
* the documented contract and must be honoured verbatim against whatever
|
|
593
|
+
* endpoint each attempt resolves (even a repeated one), so no de-duplication;
|
|
594
|
+
* - the method is safe/idempotent — an unsafe request is never duplicated;
|
|
595
|
+
* - the policy resolves a non-empty registry vendor chain (smartproxy /
|
|
596
|
+
* nodemaven). Static vendors (custom / decodo) resolve the same URL every
|
|
597
|
+
* attempt, so there is nothing to rotate or de-duplicate.
|
|
598
|
+
*/
|
|
599
|
+
export function policyRotatesTransportVendorChain(input) {
|
|
600
|
+
if (!input.usesPolicyAllocator || !input.policy || input.explicitRetry) {
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
if (UNSAFE_TRANSPORT_RETRY_METHODS.has(input.method.toUpperCase())) {
|
|
604
|
+
return false;
|
|
605
|
+
}
|
|
606
|
+
return resolveVendorChain(input.policy).length > 0;
|
|
607
|
+
}
|
|
608
|
+
export function resolvePolicyTransportAttemptCap(input) {
|
|
609
|
+
const budget = Math.max(1, Math.floor(input.retryAttempts));
|
|
610
|
+
if (!policyRotatesTransportVendorChain({
|
|
611
|
+
policy: input.policy,
|
|
612
|
+
usesPolicyAllocator: input.usesPolicyAllocator,
|
|
613
|
+
explicitRetry: input.explicitRetry,
|
|
614
|
+
method: input.method,
|
|
615
|
+
})) {
|
|
616
|
+
return budget;
|
|
617
|
+
}
|
|
618
|
+
const span = Math.min(maxPolicyProxyPoolSpan(input.policy), resolvePolicyProxyPoolSpan(input.policy));
|
|
619
|
+
return Math.max(budget, span);
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* A registry vendor chain (smartproxy/nodemaven) resolves a potentially
|
|
623
|
+
* *different* endpoint per flat attempt index, so a transport retry should
|
|
624
|
+
* advance across endpoints and de-duplicate once the chain stops yielding new
|
|
625
|
+
* ones. Static/custom/decodo policies (empty registry chain) resolve the *same*
|
|
626
|
+
* URL every attempt by design — retrying that same endpoint is intended, so the
|
|
627
|
+
* transport loop must not de-duplicate them.
|
|
628
|
+
*/
|
|
629
|
+
export function policyResolvesRegistryVendorChain(policy) {
|
|
630
|
+
return Boolean(policy) && resolveVendorChain(policy).length > 0;
|
|
631
|
+
}
|
|
544
632
|
/** Map a resolved proxy source label to the vendor that served it. */
|
|
545
633
|
export function vendorFromResolvedSource(source) {
|
|
546
634
|
if (source === "nodemaven-gateway")
|
package/dist/define.js
CHANGED
|
@@ -17,6 +17,20 @@ const VALID_PROVIDER_PROXY_AFFINITIES = [
|
|
|
17
17
|
];
|
|
18
18
|
const VALID_PROVIDER_STT_MODES = ["optional", "required"];
|
|
19
19
|
const SMARTPROXY_APP_KEY_SECRET = "APIFUSE__PROXY__SMARTPROXY_APP_KEY";
|
|
20
|
+
const NODEMAVEN_USERNAME_SECRET = "APIFUSE__PROXY__NODEMAVEN_USERNAME";
|
|
21
|
+
const NODEMAVEN_PASSWORD_SECRET = "APIFUSE__PROXY__NODEMAVEN_PASSWORD";
|
|
22
|
+
// Per-vendor provider-declared credential secrets. A required-mode chain must
|
|
23
|
+
// declare every secret of every credentialed vendor it names, so a missing
|
|
24
|
+
// credential fails at build/validation time rather than during a live outage: a
|
|
25
|
+
// declared-but-uncredentialed fallback leg is a silently dead SPOF, which is
|
|
26
|
+
// exactly the failure class the multi-vendor chain exists to remove. Vendors
|
|
27
|
+
// absent from this map (e.g. `custom`/`decodo`, whose credentials come from the
|
|
28
|
+
// `APIFUSE__PROXY__URL` bring-your-own escape hatch, not provider secrets) impose
|
|
29
|
+
// no declaration requirement.
|
|
30
|
+
const VENDOR_REQUIRED_SECRETS = {
|
|
31
|
+
smartproxy: [SMARTPROXY_APP_KEY_SECRET],
|
|
32
|
+
nodemaven: [NODEMAVEN_USERNAME_SECRET, NODEMAVEN_PASSWORD_SECRET],
|
|
33
|
+
};
|
|
20
34
|
const RESERVED_OPERATION_IDS = new Set(["auth", "health"]);
|
|
21
35
|
const MCP_TOOL_NAME_REGEX = /^[A-Za-z][A-Za-z0-9_]{0,127}$/;
|
|
22
36
|
const VALID_OPERATION_RISK_CLASSES = ["read", "write", "destructive", "external-send"];
|
|
@@ -188,20 +202,37 @@ function validateProviderProxy(config) {
|
|
|
188
202
|
throw new ValidationError(`Provider "${config.id}" has invalid proxy.session.poolSize: must be a positive integer.`);
|
|
189
203
|
}
|
|
190
204
|
}
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
205
|
+
// Every credentialed vendor in a required-mode chain must declare its
|
|
206
|
+
// provider secret(s) so a missing credential fails at build/validation time,
|
|
207
|
+
// not during a live outage. This covers the fallback legs too (not just the
|
|
208
|
+
// first vendor): a declared-but-uncredentialed nodemaven fallback would leave
|
|
209
|
+
// the chain silently down to a single vendor, reintroducing the SPOF the chain
|
|
210
|
+
// removes.
|
|
194
211
|
const vendorChain = proxy.providers && proxy.providers.length > 0
|
|
195
212
|
? proxy.providers
|
|
196
213
|
: proxy.provider
|
|
197
214
|
? [proxy.provider]
|
|
198
215
|
: [];
|
|
199
|
-
if (proxy.mode === "required"
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
216
|
+
if (proxy.mode === "required") {
|
|
217
|
+
for (const vendor of vendorChain) {
|
|
218
|
+
const requiredSecrets = VENDOR_REQUIRED_SECRETS[vendor];
|
|
219
|
+
if (!requiredSecrets)
|
|
220
|
+
continue;
|
|
221
|
+
for (const secretName of requiredSecrets) {
|
|
222
|
+
// Match the canonical runtime gate (assertRequiredSecretsPresent /
|
|
223
|
+
// listMissingRequiredSecrets), which enforces only `required === true`
|
|
224
|
+
// declarations. A declaration that omits `required` (defaulting to
|
|
225
|
+
// optional) is skipped at runtime, so accepting it here would pass
|
|
226
|
+
// validation while leaving the credential unenforced until proxy
|
|
227
|
+
// resolution during a live request — the fail-open gap this check exists
|
|
228
|
+
// to close.
|
|
229
|
+
const declared = config.secrets?.some((secret) => secret.name === secretName && secret.required === true);
|
|
230
|
+
if (!declared) {
|
|
231
|
+
throw new ValidationError(`Provider "${config.id}" requires ${vendor} egress but does not declare ${secretName}.`, {
|
|
232
|
+
fix: `Add secrets: [{ name: "${secretName}", required: true }] to the provider (every vendor in a required proxy chain must declare its credential secrets).`,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
205
236
|
}
|
|
206
237
|
}
|
|
207
238
|
// `decodo`/`custom` are deprecated vendor values (string-union members, so the
|
package/dist/runtime/choice.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual, } from "node:crypto";
|
|
2
2
|
import { assertFreshProviderChoiceIssuedAt, ProviderChoiceTokenError, } from "../choice-token.js";
|
|
3
|
-
import { ProviderError } from "../errors.js";
|
|
3
|
+
import { isProviderError, ProviderError } from "../errors.js";
|
|
4
4
|
export const PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV = "APIFUSE__PROVIDER_RUNTIME__CHOICE_TOKEN_MASTER_SECRET";
|
|
5
5
|
const PRIMARY_CHOICE_TOKEN_KID = "v1";
|
|
6
6
|
const MANAGED_CHOICE_TOKEN_VERSION = 1;
|
|
@@ -209,7 +209,25 @@ async function parseServerStoredChoice(options) {
|
|
|
209
209
|
storage,
|
|
210
210
|
contextState: options.contextState,
|
|
211
211
|
});
|
|
212
|
-
|
|
212
|
+
// Reading a server-stored choice back deserializes a persisted value. A
|
|
213
|
+
// corrupt/undecodable value would otherwise surface as a raw JSON.parse
|
|
214
|
+
// SyntaxError (or another unexpected throwable) that escapes the choice error
|
|
215
|
+
// taxonomy, gets masked as internal_error 500, and is treated as retryable by
|
|
216
|
+
// the hub -> reservation restart loop (2026-07-22 catchtable RCA, candidate A).
|
|
217
|
+
// Convert any non-branded throwable into a branded invalid_payload so it maps
|
|
218
|
+
// to a clean, non-retryable 400. Branded ProviderChoiceTokenError and genuine
|
|
219
|
+
// ProviderError (e.g. Redis-unavailable / state-unavailable) pass through so
|
|
220
|
+
// their category/retryable semantics are preserved.
|
|
221
|
+
let record;
|
|
222
|
+
try {
|
|
223
|
+
record = await namespace.get(optionsStateKey(options.handle.state_id));
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (error instanceof ProviderChoiceTokenError || isProviderError(error)) {
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
throw new ProviderChoiceTokenError("invalid_payload", "Provider choice token state payload could not be decoded.");
|
|
230
|
+
}
|
|
213
231
|
if (!record) {
|
|
214
232
|
throw new ProviderChoiceTokenError("invalid_payload", "Provider choice token state payload is missing.");
|
|
215
233
|
}
|
package/dist/runtime/http.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolveProxyConfigAsync } from "../config/loader.js";
|
|
1
|
+
import { policyRotatesTransportVendorChain, resolvePolicyTransportAttemptCap, resolveProxyConfigAsync, } from "../config/loader.js";
|
|
2
2
|
import { ProviderError, TransportError } from "../errors.js";
|
|
3
3
|
import { parseSseStream, readableBytes, readableLines, readableTextChunks } from "../stream.js";
|
|
4
4
|
import { computeProxyAttemptIndex, computeProxyTransportRetryDelayMs, createDefaultProxyTransportRetryOptions, isProxyTransportRetryMethod, normalizeProxyTransportRetryOptions, proxyTransportRetryErrorCode, proxyTransportRetryErrorStatus, shouldRetryProxyTransportAttempt, validateUnsafeProxyTransportRetryMethods, } from "./proxy-retry-policy.js";
|
|
@@ -7,6 +7,9 @@ const DEFAULT_HTTP_BASE_URL = "http://localhost";
|
|
|
7
7
|
function isHttpStatusOutcome(outcome) {
|
|
8
8
|
return "kind" in outcome && outcome.kind === "http-status";
|
|
9
9
|
}
|
|
10
|
+
function isDedupeSkipOutcome(outcome) {
|
|
11
|
+
return "kind" in outcome && outcome.kind === "dedupe-skip";
|
|
12
|
+
}
|
|
10
13
|
async function sleep(ms) {
|
|
11
14
|
if (ms <= 0)
|
|
12
15
|
return;
|
|
@@ -199,7 +202,7 @@ function normalizeNativeFetchBody(body) {
|
|
|
199
202
|
copied.set(normalized);
|
|
200
203
|
return copied.buffer;
|
|
201
204
|
}
|
|
202
|
-
async function fetchNativeHttp(baseUrl, url, method, options, clientOptions, warn, statusRetryCodes, proxyAttemptOffset = 0) {
|
|
205
|
+
async function fetchNativeHttp(baseUrl, url, method, options, clientOptions, warn, statusRetryCodes, proxyAttemptOffset = 0, dedupe) {
|
|
203
206
|
const requestUrl = appendQueryParams(resolveHttpUrl(baseUrl, url), options.params);
|
|
204
207
|
const controller = options.timeout ? new AbortController() : undefined;
|
|
205
208
|
const timeoutHandle = options.timeout
|
|
@@ -207,7 +210,20 @@ async function fetchNativeHttp(baseUrl, url, method, options, clientOptions, war
|
|
|
207
210
|
: undefined;
|
|
208
211
|
let proxy;
|
|
209
212
|
try {
|
|
213
|
+
// Resolve inside the try (and after the timeout is armed) so allocator
|
|
214
|
+
// failures are branded as TransportErrors and count against the request
|
|
215
|
+
// deadline, exactly as an inline resolve would.
|
|
210
216
|
proxy = await resolveNativeProxy(options, clientOptions, warn, proxyAttemptOffset);
|
|
217
|
+
// For a registry allocator chain, skip an endpoint a prior attempt already
|
|
218
|
+
// tried rather than re-issuing the same request. Returning the sentinel
|
|
219
|
+
// (instead of breaking) lets the loop keep advancing the flat offset until
|
|
220
|
+
// it crosses into the fallback vendor's pool span.
|
|
221
|
+
if (dedupe && proxy) {
|
|
222
|
+
if (dedupe.attempted.has(proxy)) {
|
|
223
|
+
return { kind: "dedupe-skip" };
|
|
224
|
+
}
|
|
225
|
+
dedupe.attempted.add(proxy);
|
|
226
|
+
}
|
|
211
227
|
const requestInit = {
|
|
212
228
|
headers: options.headers,
|
|
213
229
|
method,
|
|
@@ -328,22 +344,99 @@ export function createHttpClient(baseUrl, clientOptions = {}) {
|
|
|
328
344
|
const attemptOptions = statusRetryEnabled
|
|
329
345
|
? { ...headersOptions, throwOnHttpError: false }
|
|
330
346
|
: headersOptions;
|
|
331
|
-
|
|
347
|
+
// Span the whole vendor chain on transport failures. Like ctx.stealth, a
|
|
348
|
+
// policy-managed proxy resolves a *different* endpoint/vendor per attempt
|
|
349
|
+
// (the flat proxyAttemptOffset rotates across the concatenated vendor pool
|
|
350
|
+
// spans), so a transport failure should advance to the next endpoint —
|
|
351
|
+
// potentially crossing into the fallback vendor — rather than stopping at
|
|
352
|
+
// the per-endpoint retry budget and stranding the request on the primary
|
|
353
|
+
// vendor. resolvePolicyTransportAttemptCap widens the cap to the chain span
|
|
354
|
+
// only for implicit, safe-method allocator requests; explicit retry
|
|
355
|
+
// policies (their documented `attempts` ceiling), unsafe methods, and
|
|
356
|
+
// static/non-registry vendors keep the retry budget. Status-code retries
|
|
357
|
+
// stay bounded by the retry budget regardless; only transport rotation gets
|
|
358
|
+
// the full span.
|
|
359
|
+
const policyProxy = (() => {
|
|
360
|
+
const policy = clientOptions.proxyPolicy ?? clientOptions.upstream?.proxy;
|
|
361
|
+
return policy && typeof policy === "object" ? policy : undefined;
|
|
362
|
+
})();
|
|
363
|
+
const usesPolicyAllocator = Boolean(policyProxy) && !options.proxy && !clientOptions.proxy;
|
|
364
|
+
const transportAttemptCap = retryOptions
|
|
365
|
+
? resolvePolicyTransportAttemptCap({
|
|
366
|
+
policy: policyProxy,
|
|
367
|
+
usesPolicyAllocator,
|
|
368
|
+
retryAttempts: retryOptions.attempts,
|
|
369
|
+
explicitRetry,
|
|
370
|
+
method: methodName,
|
|
371
|
+
})
|
|
372
|
+
: 1;
|
|
373
|
+
// Track resolved endpoints across a policy-allocator chain. Successive
|
|
374
|
+
// attempts rotate the flat offset across the concatenated vendor pool
|
|
375
|
+
// spans, but an under-filled allocation (fewer live endpoints than the
|
|
376
|
+
// configured pool size) makes the modulo mapping repeat endpoints before
|
|
377
|
+
// the offset reaches the next vendor. Rather than re-hammering an
|
|
378
|
+
// already-tried endpoint under backoff, fetchNativeHttp returns a skip
|
|
379
|
+
// sentinel for a duplicate; the loop then advances the flat offset without
|
|
380
|
+
// issuing the request, so it keeps walking toward — and into — the fallback
|
|
381
|
+
// vendor's pool span instead of stalling on the primary vendor.
|
|
382
|
+
// De-duplication is gated on the SAME predicate that widens the attempt cap
|
|
383
|
+
// (implicit, safe-method, registry-chain rotation). It must NOT engage for
|
|
384
|
+
// an explicit retry policy: there the caller's `attempts` count is the
|
|
385
|
+
// contract and each attempt must issue against whatever endpoint it resolves
|
|
386
|
+
// — even a repeat — instead of being silently skipped (which would collapse
|
|
387
|
+
// a `poolSize: 1` + `attempts: 3` request to a single fetch).
|
|
388
|
+
const dedupeAllocatorEndpoints = policyRotatesTransportVendorChain({
|
|
389
|
+
policy: policyProxy,
|
|
390
|
+
usesPolicyAllocator,
|
|
391
|
+
explicitRetry,
|
|
392
|
+
method: methodName,
|
|
393
|
+
});
|
|
394
|
+
const dedupeContext = dedupeAllocatorEndpoints
|
|
395
|
+
? { attempted: new Set() }
|
|
396
|
+
: undefined;
|
|
397
|
+
const executeOnce = (proxyAttemptOffset = 0) => fetchNativeHttp(baseUrl, url, methodName, attemptOptions, clientOptions, warnOnce, statusRetryEnabled ? retryOptions?.statusCodes : undefined, proxyAttemptOffset, dedupeContext);
|
|
332
398
|
if (!retryEnabled || !retryOptions) {
|
|
333
399
|
const outcome = await executeOnce();
|
|
400
|
+
if (isDedupeSkipOutcome(outcome)) {
|
|
401
|
+
// Single-shot path never de-duplicates (dedupeContext is undefined),
|
|
402
|
+
// but keep the union total.
|
|
403
|
+
throw new TransportError("HTTP request produced no terminal result", {
|
|
404
|
+
code: "retry_exhausted",
|
|
405
|
+
});
|
|
406
|
+
}
|
|
334
407
|
if (isHttpStatusOutcome(outcome)) {
|
|
335
408
|
throw toUpstreamHttpError(outcome.status);
|
|
336
409
|
}
|
|
337
410
|
return outcome;
|
|
338
411
|
}
|
|
412
|
+
let lastError;
|
|
339
413
|
let lastErrorCode;
|
|
340
414
|
let lastStatus;
|
|
341
|
-
|
|
415
|
+
// `attempt` walks the flat proxy offset across the full chain span; `issued`
|
|
416
|
+
// counts requests that were actually sent (skipped duplicate offsets do not
|
|
417
|
+
// increment it). Retry summaries and the status-retry budget must reflect
|
|
418
|
+
// issued requests, not the raw offset, so they stay accurate when partial
|
|
419
|
+
// allocations skip offsets.
|
|
420
|
+
let issued = 0;
|
|
421
|
+
for (let attempt = 1; attempt <= transportAttemptCap; attempt += 1) {
|
|
422
|
+
// Whether this offset actually issued a request (vs. a skipped duplicate),
|
|
423
|
+
// so the catch counts a thrown *transport* failure once without
|
|
424
|
+
// double-counting a status outcome that already incremented before it
|
|
425
|
+
// re-threw as an upstream HTTP error.
|
|
426
|
+
let issuedThisAttempt = false;
|
|
342
427
|
try {
|
|
343
428
|
const outcome = await executeOnce(attempt - 1);
|
|
429
|
+
if (isDedupeSkipOutcome(outcome)) {
|
|
430
|
+
// Duplicate endpoint from a partial allocation: advance the flat
|
|
431
|
+
// offset without issuing the request (no backoff, not a failure) so
|
|
432
|
+
// the loop keeps rotating toward the fallback vendor.
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
issued += 1;
|
|
436
|
+
issuedThisAttempt = true;
|
|
344
437
|
if (isHttpStatusOutcome(outcome)) {
|
|
345
438
|
lastStatus = outcome.status;
|
|
346
|
-
if (outcome.retryable &&
|
|
439
|
+
if (outcome.retryable && issued < retryOptions.attempts) {
|
|
347
440
|
await sleep(computeProxyTransportRetryDelayMs(retryOptions, attempt, outcome.headers));
|
|
348
441
|
continue;
|
|
349
442
|
}
|
|
@@ -353,10 +446,10 @@ export function createHttpClient(baseUrl, clientOptions = {}) {
|
|
|
353
446
|
if (response.status >= 400 && headersOptions.throwOnHttpError !== false) {
|
|
354
447
|
throw toUpstreamHttpError(response.status);
|
|
355
448
|
}
|
|
356
|
-
if (
|
|
449
|
+
if (issued > 1) {
|
|
357
450
|
const summary = {
|
|
358
|
-
attempts:
|
|
359
|
-
retries:
|
|
451
|
+
attempts: issued,
|
|
452
|
+
retries: issued - 1,
|
|
360
453
|
...(retryOptions.preset ? { preset: retryOptions.preset } : {}),
|
|
361
454
|
transport: "native",
|
|
362
455
|
...(lastErrorCode ? { lastErrorCode } : {}),
|
|
@@ -367,10 +460,13 @@ export function createHttpClient(baseUrl, clientOptions = {}) {
|
|
|
367
460
|
return response;
|
|
368
461
|
}
|
|
369
462
|
catch (error) {
|
|
463
|
+
if (!issuedThisAttempt)
|
|
464
|
+
issued += 1;
|
|
465
|
+
lastError = error;
|
|
370
466
|
lastErrorCode = proxyTransportRetryErrorCode(error);
|
|
371
467
|
lastStatus = proxyTransportRetryErrorStatus(error);
|
|
372
468
|
const proxyUsed = Boolean(error.proxyUsed);
|
|
373
|
-
if (attempt <
|
|
469
|
+
if (attempt < transportAttemptCap &&
|
|
374
470
|
shouldRetryProxyTransportAttempt({
|
|
375
471
|
error,
|
|
376
472
|
explicitRetry,
|
|
@@ -384,6 +480,13 @@ export function createHttpClient(baseUrl, clientOptions = {}) {
|
|
|
384
480
|
throw error;
|
|
385
481
|
}
|
|
386
482
|
}
|
|
483
|
+
// Reached when the attempt cap is consumed without a terminal outcome —
|
|
484
|
+
// e.g. the final offsets of a partial allocation all resolved to
|
|
485
|
+
// already-tried endpoints and were skipped. Surface the last real transport
|
|
486
|
+
// failure rather than a synthetic exhaustion error.
|
|
487
|
+
if (lastError !== undefined) {
|
|
488
|
+
throw lastError;
|
|
489
|
+
}
|
|
387
490
|
throw new TransportError("HTTP retry exhausted without a terminal result", {
|
|
388
491
|
code: "retry_exhausted",
|
|
389
492
|
});
|
|
@@ -11,6 +11,7 @@ export type ProxyProtocol = "http" | "socks5";
|
|
|
11
11
|
* NodeMaven never defaults to socks5.
|
|
12
12
|
*/
|
|
13
13
|
export declare const NODEMAVEN_DEFAULT_PROTOCOL: ProxyProtocol;
|
|
14
|
+
export declare const NODEMAVEN_MAX_POOL_SIZE = 50;
|
|
14
15
|
export declare function hasNodemavenCredentials(): boolean;
|
|
15
16
|
export declare function nodemavenPoolSize(policy: ProviderProxyPolicy): number;
|
|
16
17
|
export type NodemavenSynthesisInput = {
|
|
@@ -17,7 +17,7 @@ const NODEMAVEN_PORTS = {
|
|
|
17
17
|
const NODEMAVEN_FILTERS = new Set(["medium", "high"]);
|
|
18
18
|
const DEFAULT_NODEMAVEN_FILTER = "medium";
|
|
19
19
|
const DEFAULT_NODEMAVEN_POOL_SIZE = 20;
|
|
20
|
-
const NODEMAVEN_MAX_POOL_SIZE = 50;
|
|
20
|
+
export const NODEMAVEN_MAX_POOL_SIZE = 50;
|
|
21
21
|
/** NodeMaven sticky sessions persist up to 24h server-side, keyed by the sid. */
|
|
22
22
|
const NODEMAVEN_MAX_LIFETIME_MINUTES = 1440;
|
|
23
23
|
const SID_LENGTH = 10;
|
package/dist/runtime/state.js
CHANGED
|
@@ -69,7 +69,18 @@ function resolveExpiresAt(ttl) {
|
|
|
69
69
|
function envelopeFromJson(key, raw) {
|
|
70
70
|
if (!raw)
|
|
71
71
|
return null;
|
|
72
|
-
|
|
72
|
+
// A corrupt/undecodable persisted envelope must be treated as absent rather
|
|
73
|
+
// than throwing a raw JSON.parse SyntaxError: an uncaught SyntaxError escapes
|
|
74
|
+
// the provider error taxonomy, is masked as internal_error 500, and is then
|
|
75
|
+
// retried by the hub (2026-07-22 catchtable reserve RCA, candidate A). Returning
|
|
76
|
+
// null also keeps list() from aborting the whole scan on a single bad entry.
|
|
77
|
+
let parsed;
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(raw);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
73
84
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
74
85
|
return null;
|
|
75
86
|
}
|
package/dist/runtime/stealth.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { Impit } from "impit";
|
|
3
|
-
import { DEFAULT_SMARTPROXY_POOL_SIZE, invalidateProxyResolutionCacheAsync, ProxyResolutionError, resolvePolicyProxyPoolSpan,
|
|
3
|
+
import { DEFAULT_SMARTPROXY_POOL_SIZE, invalidateProxyResolutionCacheAsync, policyResolvesRegistryVendorChain, ProxyResolutionError, resolvePolicyProxyPoolSpan, resolvePolicyTransportAttemptCap, resolveProxyConfigAsync, vendorFromResolvedSource, } from "../config/loader.js";
|
|
4
4
|
import { SDKError, TransportError } from "../errors.js";
|
|
5
5
|
import { getStealthProfile } from "../stealth/profiles.js";
|
|
6
6
|
import { createProxyAuthIpDeniedError, createProxyEdgeAuthRejectedError, createProxyEdgeTlsRejectedError, createProxyPoolExhaustedError, createProxyPoolStaleError, isProxyAuthIpDeniedMessage, isProxyEdgeAuthRejectedMessage, isProxyEdgeTlsRejectedResponse, isProxyPoolRefreshableError, isProxyPoolStaleMessage, isProxyPoolStaleStatus, PROXY_EDGE_AUTH_REJECTED_CODE, PROXY_POOL_STALE_CODE, } from "./proxy-errors.js";
|
|
@@ -8,11 +8,6 @@ import { computeProxyAttemptIndex, computeProxyTransportRetryDelayMs, createDefa
|
|
|
8
8
|
import { appendQueryParams } from "./request-options.js";
|
|
9
9
|
const DEFAULT_PROFILE = "chrome-146";
|
|
10
10
|
const MISSING_PROXY_WARNING = "[provider-sdk] Provider requested proxy routing, but no proxy URL was configured. Continuing without proxy.";
|
|
11
|
-
/**
|
|
12
|
-
* Upper bound on attempts across a multi-vendor chain, so a two-vendor chain can
|
|
13
|
-
* exhaust each vendor's pool before failing over and finally throwing.
|
|
14
|
-
*/
|
|
15
|
-
const MAX_POLICY_PROXY_TOTAL_ATTEMPTS = SMARTPROXY_MAX_POOL_SIZE * 2;
|
|
16
11
|
const MAX_POLICY_PROXY_POOL_REFRESHES = 1;
|
|
17
12
|
const PROXY_CONNECT_FAILURE_CODE = "proxy_connect_failed";
|
|
18
13
|
const PROXY_CONNECT_FAILURE_BODY_PATTERN = /\bproxy\b.*\b(non[\s-]?200|connect|tunnel)|\bconnect\b.*\bproxy\b|\btunnel\b/i;
|
|
@@ -521,8 +516,22 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
|
|
|
521
516
|
(typeof clientOptions.upstream?.proxy === "object"
|
|
522
517
|
? clientOptions.upstream.proxy
|
|
523
518
|
: undefined);
|
|
524
|
-
|
|
525
|
-
|
|
519
|
+
// The pool span is already bounded by each vendor's max pool size
|
|
520
|
+
// (smartproxy ≤20, nodemaven ≤50), so the configured span never exceeds
|
|
521
|
+
// the chain's true maximum — a large NodeMaven pool stays fully
|
|
522
|
+
// reachable rather than being truncated at an arbitrary ceiling.
|
|
523
|
+
const policyProxyAttemptCap = Math.max(1, policyProxy ? resolvePolicyProxyPoolSpan(policyProxy) : DEFAULT_SMARTPROXY_POOL_SIZE);
|
|
524
|
+
// A registry vendor chain (smartproxy/nodemaven) is the only policy whose
|
|
525
|
+
// successive attempts resolve a *different* endpoint, so it is the only one
|
|
526
|
+
// that may widen the attempt cap to the pool span, de-duplicate endpoints,
|
|
527
|
+
// and drive allocator stale-pool refresh. A static custom/decodo policy
|
|
528
|
+
// resolves the same URL every attempt: widening/refreshing it would resend
|
|
529
|
+
// the request dozens of times (up to maxAttempts × refreshes) and bypass
|
|
530
|
+
// retry:false and unsafe-method controls. Static policies therefore follow
|
|
531
|
+
// the ordinary transport-retry budget instead.
|
|
532
|
+
const rotatesRegistryChain = usesPolicyAllocator && policyResolvesRegistryVendorChain(policyProxy);
|
|
533
|
+
const maxAttempts = rotatesRegistryChain ? policyProxyAttemptCap : retryAttemptCap;
|
|
534
|
+
const dedupeAllocatorEndpoints = rotatesRegistryChain;
|
|
526
535
|
let lastError;
|
|
527
536
|
for (let refreshAttempt = 0; refreshAttempt <= MAX_POLICY_PROXY_POOL_REFRESHES; refreshAttempt += 1) {
|
|
528
537
|
let stalePoolError;
|
|
@@ -554,9 +563,14 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
|
|
|
554
563
|
assertNoUnsupportedFingerprintOverrides(options);
|
|
555
564
|
attemptProxy = await resolveRequestProxy(options, attempt, refreshAttempt);
|
|
556
565
|
proxy = attemptProxy.url;
|
|
557
|
-
if (proxy &&
|
|
566
|
+
if (proxy && dedupeAllocatorEndpoints) {
|
|
567
|
+
// An under-filled allocation repeats endpoints (via the modulo
|
|
568
|
+
// pool mapping) before the flat offset crosses into the next
|
|
569
|
+
// vendor. Skip an already-tried endpoint and advance the offset
|
|
570
|
+
// rather than breaking — breaking here would strand the request on
|
|
571
|
+
// the primary vendor and never reach the fallback leg.
|
|
558
572
|
if (attemptedProxies.has(proxy)) {
|
|
559
|
-
|
|
573
|
+
continue;
|
|
560
574
|
}
|
|
561
575
|
attemptedProxies.add(proxy);
|
|
562
576
|
}
|
|
@@ -616,7 +630,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
|
|
|
616
630
|
const normalizedError = normalizeStealthTransportError(error);
|
|
617
631
|
recordProxyAttempt("error", proxyAttemptErrorCode(normalizedError), proxyAttemptStatus(normalizedError));
|
|
618
632
|
lastError = normalizedError;
|
|
619
|
-
if (proxy &&
|
|
633
|
+
if (proxy && rotatesRegistryChain && isProxyPoolRefreshableError(normalizedError)) {
|
|
620
634
|
stalePoolError = normalizedError;
|
|
621
635
|
if (shouldRunProxyAuthDiagnostic(normalizedError)) {
|
|
622
636
|
stalePoolDiagnosticProxy = proxy;
|
|
@@ -626,10 +640,27 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
|
|
|
626
640
|
}
|
|
627
641
|
break;
|
|
628
642
|
}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
643
|
+
// Cap the number of transport retries. For a policy-allocator chain,
|
|
644
|
+
// every attempt resolves a *different* endpoint/vendor (poolIndex
|
|
645
|
+
// rotates across the concatenated vendor pool spans), so a transport
|
|
646
|
+
// failure is a signal to advance to the next endpoint — potentially
|
|
647
|
+
// crossing into the fallback vendor — not to retry the same endpoint.
|
|
648
|
+
// Truncating that rotation at the per-endpoint retry budget would
|
|
649
|
+
// strand the request on the primary vendor and never reach the
|
|
650
|
+
// fallback, since the crossover only happens once the flat attempt
|
|
651
|
+
// index exceeds the primary vendor's pool size (~10-20).
|
|
652
|
+
// resolvePolicyTransportAttemptCap widens to the full chain span only
|
|
653
|
+
// for implicit, safe-method allocator requests; explicit retry
|
|
654
|
+
// policies (their documented `attempts` ceiling), unsafe methods, and
|
|
655
|
+
// static/non-registry vendors keep the per-endpoint retry budget.
|
|
656
|
+
const transportRetryCap = resolvePolicyTransportAttemptCap({
|
|
657
|
+
policy: policyProxy,
|
|
658
|
+
usesPolicyAllocator,
|
|
659
|
+
retryAttempts: stealthRetryOptions?.attempts ?? 1,
|
|
660
|
+
explicitRetry: hasExplicitRetryPolicy,
|
|
661
|
+
method,
|
|
662
|
+
});
|
|
663
|
+
if (attempt + 1 < transportRetryCap &&
|
|
633
664
|
shouldRetryProxyTransportAttempt({
|
|
634
665
|
error: normalizedError,
|
|
635
666
|
explicitRetry: hasExplicitRetryPolicy,
|
|
@@ -645,7 +676,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
|
|
|
645
676
|
throw normalizedError;
|
|
646
677
|
}
|
|
647
678
|
}
|
|
648
|
-
if (
|
|
679
|
+
if (rotatesRegistryChain &&
|
|
649
680
|
stalePoolError &&
|
|
650
681
|
refreshAttempt < MAX_POLICY_PROXY_POOL_REFRESHES) {
|
|
651
682
|
await invalidateProxyResolutionCacheAsync({
|