@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 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.
@@ -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
  /**
@@ -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
- // Smartproxy uses a provider-declared secret; when it is a required-mode
192
- // vendor (singular or in the chain) the app key must be declared so a missing
193
- // credential fails at build/validation time, not during a live outage.
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" && vendorChain.includes("smartproxy")) {
200
- const hasSmartproxySecret = config.secrets?.some((secret) => secret.name === SMARTPROXY_APP_KEY_SECRET && secret.required !== false);
201
- if (!hasSmartproxySecret) {
202
- throw new ValidationError(`Provider "${config.id}" requires Smartproxy egress but does not declare ${SMARTPROXY_APP_KEY_SECRET}.`, {
203
- fix: `Add secrets: [{ name: "${SMARTPROXY_APP_KEY_SECRET}", required: true }] to the provider.`,
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
@@ -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
- const record = await namespace.get(optionsStateKey(options.handle.state_id));
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
  }
@@ -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
- const executeOnce = (proxyAttemptOffset = 0) => fetchNativeHttp(baseUrl, url, methodName, attemptOptions, clientOptions, warnOnce, statusRetryEnabled ? retryOptions?.statusCodes : undefined, proxyAttemptOffset);
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
- for (let attempt = 1; attempt <= retryOptions.attempts; attempt += 1) {
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 && attempt < retryOptions.attempts) {
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 (attempt > 1) {
449
+ if (issued > 1) {
357
450
  const summary = {
358
- attempts: attempt,
359
- retries: attempt - 1,
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 < retryOptions.attempts &&
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;
@@ -69,7 +69,18 @@ function resolveExpiresAt(ttl) {
69
69
  function envelopeFromJson(key, raw) {
70
70
  if (!raw)
71
71
  return null;
72
- const parsed = JSON.parse(raw);
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
  }
@@ -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, resolveProxyConfigAsync, SMARTPROXY_MAX_POOL_SIZE, vendorFromResolvedSource, } from "../config/loader.js";
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
- const policyProxyAttemptCap = Math.max(1, Math.min(MAX_POLICY_PROXY_TOTAL_ATTEMPTS, policyProxy ? resolvePolicyProxyPoolSpan(policyProxy) : DEFAULT_SMARTPROXY_POOL_SIZE));
525
- const maxAttempts = usesPolicyAllocator ? policyProxyAttemptCap : retryAttemptCap;
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 && usesPolicyAllocator) {
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
- break;
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 && usesPolicyAllocator && isProxyPoolRefreshableError(normalizedError)) {
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
- if (attempt + 1 <
630
- (stealthRetryOptions
631
- ? Math.min(maxAttempts, stealthRetryOptions.attempts)
632
- : maxAttempts) &&
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 (usesPolicyAllocator &&
679
+ if (rotatesRegistryChain &&
649
680
  stalePoolError &&
650
681
  refreshAttempt < MAX_POLICY_PROXY_POOL_REFRESHES) {
651
682
  await invalidateProxyResolutionCacheAsync({