@apifuse/provider-sdk 2.2.0-beta.7 → 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.
@@ -4,8 +4,25 @@ import path from "node:path";
4
4
 
5
5
  import { Redis } from "ioredis";
6
6
 
7
- import type { ProviderProxyPolicy, TraceConfig } from "../types.js";
8
-
7
+ import type { ProviderProxyPolicy, ProviderProxyProvider, TraceConfig } from "../types.js";
8
+ import {
9
+ NODEMAVEN_DEFAULT_PROTOCOL,
10
+ NODEMAVEN_MAX_POOL_SIZE,
11
+ type ProxyProtocol,
12
+ hasNodemavenCredentials,
13
+ nodemavenPoolSize,
14
+ synthesizeNodemavenProxy,
15
+ } from "../runtime/proxy-nodemaven.js";
16
+
17
+ export type { ProxyProtocol } from "../runtime/proxy-nodemaven.js";
18
+
19
+ /** Proxy vendors the SDK resolves natively (as opposed to the static env path). */
20
+ export type ProxyVendorName = "smartproxy" | "nodemaven";
21
+
22
+ // "smartproxy" here is api.smartproxy.org — a residential proxy with an IP
23
+ // extraction API (app_key → raw ip:port pool). It is NOT the company formerly
24
+ // named Smartproxy (smartproxy.com), which rebranded to Decodo in 2025 and is
25
+ // modelled separately as the `decodo` gateway vendor. Do not conflate them.
9
26
  export const SMARTPROXY_APP_KEY_ENV = "APIFUSE__PROXY__SMARTPROXY_APP_KEY";
10
27
  export const SMARTPROXY_MAX_LIFETIME_MINUTES = 2000;
11
28
  export const DEFAULT_SMARTPROXY_POOL_SIZE = 20;
@@ -52,6 +69,26 @@ export type ProxyResolutionOptions = {
52
69
  affinityKey?: string;
53
70
  /** Zero-based proxy-pool attempt index used by SDK transports for failover. */
54
71
  proxyAttempt?: number;
72
+ /**
73
+ * Tunnelling protocols the calling transport can use. When a resolved
74
+ * protocol is not in this set the resolver fails with
75
+ * `PROXY_PROTOCOL_UNSUPPORTED` instead of silently downgrading. Unset means
76
+ * permissive (both protocols allowed).
77
+ */
78
+ transportProtocols?: readonly ProxyProtocol[];
79
+ /**
80
+ * Explicit protocol override. Internal — for the verification harness and
81
+ * tests, or an advanced caller. Normal callers omit it and each vendor uses
82
+ * its own benchmarked default protocol (see VENDOR_DEFAULT_PROTOCOL). Not an
83
+ * env var and not a provider-policy field.
84
+ */
85
+ protocol?: ProxyProtocol;
86
+ /**
87
+ * Gateway pool "refresh" generation. Bumped by transports on pool refresh to
88
+ * derive a fresh gateway session set (ignored by allocation-style vendors,
89
+ * whose refresh is driven by cache invalidation).
90
+ */
91
+ proxyRefreshEpoch?: number;
55
92
  telemetry?: ProxyTelemetrySink;
56
93
  };
57
94
 
@@ -74,7 +111,8 @@ export type SmartproxyAllocatorBodyClass =
74
111
  | "usable_proxy_endpoints";
75
112
 
76
113
  export type ProxyResolutionTelemetryEvent = {
77
- provider: "smartproxy";
114
+ provider: ProxyVendorName;
115
+ protocol?: ProxyProtocol;
78
116
  cacheStatus: ProxyCacheStatus;
79
117
  cacheHit: boolean;
80
118
  resolutionMs: number;
@@ -92,7 +130,7 @@ export type ProxyResolutionTelemetryEvent = {
92
130
  };
93
131
 
94
132
  export type ProxyAttemptTelemetryEvent = {
95
- provider: "smartproxy";
133
+ provider: ProxyVendorName;
96
134
  attempt: number;
97
135
  poolIndex?: number;
98
136
  proxyHash?: string;
@@ -102,31 +140,60 @@ export type ProxyAttemptTelemetryEvent = {
102
140
  durationMs?: number;
103
141
  };
104
142
 
143
+ export type ProxyVendorFailoverTelemetryEvent = {
144
+ /** Vendor that failed or was skipped. */
145
+ vendor: ProxyVendorName;
146
+ /** Vendor tried next, or undefined when the chain is exhausted. */
147
+ nextVendor?: ProxyVendorName;
148
+ phase: "resolution" | "transport";
149
+ reason: "no_credentials" | "allocation_failed" | "pool_exhausted" | "protocol_unsupported";
150
+ attempt?: number;
151
+ };
152
+
105
153
  export type ProxyTelemetrySink = {
106
154
  recordProxyResolution(event: ProxyResolutionTelemetryEvent): void;
107
155
  recordProxyAttempt?(event: ProxyAttemptTelemetryEvent): void;
156
+ recordProxyVendorFailover?(event: ProxyVendorFailoverTelemetryEvent): void;
108
157
  };
109
158
 
110
159
  export type ResolvedProxyConfig = {
111
160
  shouldWarn: boolean;
112
161
  url?: string;
113
- source?: "explicit" | "env" | "config" | "smartproxy-allocator";
162
+ source?: "explicit" | "env" | "config" | "smartproxy-allocator" | "nodemaven-gateway";
163
+ protocol?: ProxyProtocol;
114
164
  diagnostics?: Record<string, string | number | boolean>;
115
165
  };
116
166
 
167
+ export type ProxyResolutionErrorCode =
168
+ | "PROXY_REQUIRED"
169
+ | "PROXY_ALLOCATION_FAILED"
170
+ | "PROXY_PROTOCOL_UNSUPPORTED";
171
+
117
172
  export class ProxyResolutionError extends Error {
118
- readonly code: "PROXY_REQUIRED" | "PROXY_ALLOCATION_FAILED";
173
+ readonly code: ProxyResolutionErrorCode;
119
174
  readonly telemetry?: ProxyResolutionTelemetryEvent;
175
+ readonly vendor?: ProxyVendorName;
176
+ readonly vendorChain?: ProxyVendorName[];
177
+ readonly protocol?: ProxyProtocol;
120
178
 
121
179
  constructor(
122
- code: "PROXY_REQUIRED" | "PROXY_ALLOCATION_FAILED",
180
+ code: ProxyResolutionErrorCode,
123
181
  message: string,
124
- options?: { cause?: unknown; telemetry?: ProxyResolutionTelemetryEvent },
182
+ options?: {
183
+ cause?: unknown;
184
+ telemetry?: ProxyResolutionTelemetryEvent;
185
+ vendor?: ProxyVendorName;
186
+ vendorChain?: ProxyVendorName[];
187
+ protocol?: ProxyProtocol;
188
+ },
125
189
  ) {
126
190
  super(message, options);
127
191
  this.name = "ProxyResolutionError";
128
192
  this.code = code;
129
193
  this.telemetry = options?.telemetry;
194
+ this.vendor = options?.vendor;
195
+ this.vendorChain = options?.vendorChain;
196
+ this.protocol = options?.protocol;
130
197
  }
131
198
  }
132
199
 
@@ -389,6 +456,11 @@ function applyStickyProxySession(proxyUrl: string): string {
389
456
  return proxyUrl;
390
457
  }
391
458
 
459
+ // This rewrites sticky-session usernames for a bring-your-own *gateway* URL
460
+ // (APIFUSE__PROXY__URL). The `smartproxy` host here means a smartproxy.com /
461
+ // Decodo-family gateway that authenticates by username — NOT the
462
+ // api.smartproxy.org allocation vendor, whose endpoints are raw ip:port with
463
+ // no credentials and therefore return early above.
392
464
  const host = parsed.hostname.toLowerCase();
393
465
  if (!host.includes("smartproxy") && !host.includes("decodo")) {
394
466
  return proxyUrl;
@@ -477,60 +549,214 @@ export async function resolveProxyConfigAsync(
477
549
  return { shouldWarn: false };
478
550
  }
479
551
 
480
- const provider = resolveProxyProvider(policy);
481
- if (provider !== "smartproxy") {
552
+ const chain = resolveVendorChain(policy);
553
+ if (chain.length === 0) {
554
+ // decodo/custom/env-static providers keep the legacy static-URL path.
482
555
  return resolveProxyConfig({
483
556
  ...options,
484
557
  upstream: { proxy: true },
485
558
  });
486
559
  }
487
560
 
488
- const appKey = process.env[SMARTPROXY_APP_KEY_ENV]?.trim();
489
- if (!appKey) {
490
- if (policy.mode === "required") {
561
+ // Protocol is chosen per vendor (each vendor's benchmarked-best), with an
562
+ // optional explicit override for the harness/tests. Both are tunnelling
563
+ // schemes. transportProtocols is what the calling transport can actually use.
564
+ const transportProtocols = options.transportProtocols ?? (["http", "socks5"] as const);
565
+
566
+ const sizes = chain.map((vendor) => vendorPoolSize(vendor, policy));
567
+ const total = sizes.reduce((sum, size) => sum + size, 0);
568
+ const normalizedAttempt = normalizeAttemptIndex(options.proxyAttempt);
569
+ const { vendorIndex: startVendorIndex, poolIndex: startPoolIndex } = mapFlatAttempt(
570
+ total > 0 ? normalizedAttempt % total : 0,
571
+ sizes,
572
+ );
573
+ const refreshEpoch = normalizeAttemptIndex(options.proxyRefreshEpoch);
574
+
575
+ let lastError: unknown;
576
+ let blockedProtocol: ProxyProtocol | undefined;
577
+ for (let vendorIndex = startVendorIndex; vendorIndex < chain.length; vendorIndex++) {
578
+ const vendor = chain[vendorIndex] as ProxyVendorName;
579
+ const nextVendor = chain[vendorIndex + 1];
580
+ const poolIndex = vendorIndex === startVendorIndex ? startPoolIndex : 0;
581
+ const protocol = options.protocol ?? VENDOR_DEFAULT_PROTOCOL[vendor];
582
+
583
+ if (!vendorHasCredentials(vendor)) {
584
+ options.telemetry?.recordProxyVendorFailover?.({
585
+ vendor,
586
+ nextVendor,
587
+ phase: "resolution",
588
+ reason: "no_credentials",
589
+ });
590
+ continue;
591
+ }
592
+
593
+ // The calling transport must be able to use this vendor's protocol; if not,
594
+ // fail over to the next vendor rather than silently downgrading.
595
+ if (!transportProtocols.includes(protocol)) {
596
+ blockedProtocol = protocol;
597
+ options.telemetry?.recordProxyVendorFailover?.({
598
+ vendor,
599
+ nextVendor,
600
+ phase: "resolution",
601
+ reason: "protocol_unsupported",
602
+ });
603
+ continue;
604
+ }
605
+
606
+ try {
607
+ return await resolveWithVendor(vendor, policy, options, {
608
+ protocol,
609
+ poolIndex,
610
+ refreshEpoch,
611
+ });
612
+ } catch (error) {
613
+ // Config/programming errors (invalid filter, etc.) are not vendor
614
+ // outages — propagate them rather than failing over.
615
+ if (!(error instanceof ProxyResolutionError)) {
616
+ throw error;
617
+ }
618
+ if (error.telemetry) {
619
+ options.telemetry?.recordProxyResolution(error.telemetry);
620
+ }
621
+ lastError = error;
622
+ options.telemetry?.recordProxyVendorFailover?.({
623
+ vendor,
624
+ nextVendor,
625
+ phase: "resolution",
626
+ reason: "allocation_failed",
627
+ });
628
+ }
629
+ }
630
+
631
+ if (policy.mode === "required") {
632
+ if (lastError) {
633
+ throw lastError instanceof ProxyResolutionError
634
+ ? lastError
635
+ : new ProxyResolutionError(
636
+ "PROXY_ALLOCATION_FAILED",
637
+ `All proxy vendors [${chain.join(", ")}] failed for required proxy egress.`,
638
+ { cause: lastError, vendorChain: chain },
639
+ );
640
+ }
641
+ if (blockedProtocol) {
491
642
  throw new ProxyResolutionError(
492
- "PROXY_REQUIRED",
493
- `Smartproxy egress is required but ${SMARTPROXY_APP_KEY_ENV} is not configured.`,
643
+ "PROXY_PROTOCOL_UNSUPPORTED",
644
+ `No proxy vendor in [${chain.join(", ")}] could serve a protocol supported by this transport (supports: ${transportProtocols.join(", ")}; vendor wanted "${blockedProtocol}"). Route this provider through the stealth transport.`,
645
+ { protocol: blockedProtocol, vendorChain: chain },
494
646
  );
495
647
  }
496
- return { shouldWarn: true };
648
+ throw new ProxyResolutionError(
649
+ "PROXY_REQUIRED",
650
+ `Proxy egress is required but no vendor credentials are configured. Missing: ${chain
651
+ .map((vendor) => `${missingCredentialEnv(vendor)} (${vendor})`)
652
+ .join(", ")}.`,
653
+ { vendorChain: chain },
654
+ );
497
655
  }
498
- const lifetimeMinutes = resolveSmartproxyLifetime(policy);
656
+ return { shouldWarn: true };
657
+ }
658
+
659
+ /**
660
+ * Each vendor's default egress protocol, chosen from live KR benchmarks. HTTP
661
+ * CONNECT wins for nodemaven (socks5 adds ~500ms through the gateway) and ties
662
+ * for smartproxy, and is the only protocol ctx.http (Bun native fetch) supports.
663
+ * Override per call via ProxyResolutionOptions.protocol (harness/tests).
664
+ */
665
+ const VENDOR_DEFAULT_PROTOCOL: Record<ProxyVendorName, ProxyProtocol> = {
666
+ smartproxy: "http",
667
+ nodemaven: NODEMAVEN_DEFAULT_PROTOCOL,
668
+ };
499
669
 
670
+ /**
671
+ * Guard the No-MITM invariant: a resolved proxy URL must use a tunnelling scheme
672
+ * (http CONNECT or socks5) so the client TLS handshake reaches the origin
673
+ * end-to-end. Anything else would intercept TLS and break fingerprinting.
674
+ */
675
+ export function assertTunnelingScheme(url: string): void {
676
+ let scheme: string;
500
677
  try {
501
- const allocated = await allocateSmartproxy(
502
- policy,
503
- appKey,
504
- lifetimeMinutes,
505
- options.affinityKey,
678
+ scheme = new URL(url).protocol.replace(/:$/, "").toLowerCase();
679
+ } catch {
680
+ throw new ProxyResolutionError("PROXY_ALLOCATION_FAILED", `Malformed proxy URL: ${url}`);
681
+ }
682
+ if (scheme !== "http" && scheme !== "socks5") {
683
+ throw new ProxyResolutionError(
684
+ "PROXY_ALLOCATION_FAILED",
685
+ `Resolved proxy scheme "${scheme}" is not a tunnelling scheme (expected http or socks5). Refusing to route TLS through a non-tunnelling proxy.`,
506
686
  );
507
- options.telemetry?.recordProxyResolution(allocated.telemetry);
508
- const poolIndex = selectProxyPoolIndex(allocated.pool.urls.length, options.proxyAttempt);
687
+ }
688
+ }
689
+
690
+ async function resolveWithVendor(
691
+ vendor: ProxyVendorName,
692
+ policy: ProviderProxyPolicy,
693
+ options: ProxyResolutionOptions,
694
+ context: { protocol: ProxyProtocol; poolIndex: number; refreshEpoch: number },
695
+ ): Promise<ResolvedProxyConfig> {
696
+ if (vendor === "nodemaven") {
697
+ const startedAt = Date.now();
698
+ const synthesized = synthesizeNodemavenProxy({
699
+ policy,
700
+ affinityKey: options.affinityKey,
701
+ protocol: context.protocol,
702
+ poolIndex: context.poolIndex,
703
+ refreshEpoch: context.refreshEpoch,
704
+ country: resolveSmartproxyCountry(policy),
705
+ });
706
+ options.telemetry?.recordProxyResolution({
707
+ provider: "nodemaven",
708
+ protocol: synthesized.protocol,
709
+ cacheStatus: "disabled",
710
+ cacheHit: false,
711
+ resolutionMs: Math.max(0, Date.now() - startedAt),
712
+ attempts: 1,
713
+ });
714
+ assertTunnelingScheme(synthesized.url);
509
715
  return {
510
716
  shouldWarn: false,
511
- url: allocated.pool.urls[poolIndex],
512
- source: "smartproxy-allocator",
717
+ url: synthesized.url,
718
+ source: "nodemaven-gateway",
719
+ protocol: synthesized.protocol,
513
720
  diagnostics: {
514
- ...allocated.pool.diagnostics,
515
- poolSize: allocated.pool.urls.length,
516
- poolIndex,
721
+ ...synthesized.diagnostics,
722
+ poolIndex: context.poolIndex,
517
723
  },
518
724
  };
519
- } catch (error) {
520
- if (error instanceof ProxyResolutionError && error.telemetry) {
521
- options.telemetry?.recordProxyResolution(error.telemetry);
522
- }
523
- if (policy.mode === "required") {
524
- throw error instanceof ProxyResolutionError
525
- ? error
526
- : new ProxyResolutionError(
527
- "PROXY_ALLOCATION_FAILED",
528
- "Smartproxy allocator failed for required proxy egress.",
529
- { cause: error },
530
- );
531
- }
532
- return { shouldWarn: true };
533
725
  }
726
+
727
+ // smartproxy allocation-style vendor.
728
+ const appKey = process.env[SMARTPROXY_APP_KEY_ENV]?.trim();
729
+ if (!appKey) {
730
+ // Guarded by vendorHasCredentials; treated as a vendor-internal failure.
731
+ throw new ProxyResolutionError(
732
+ "PROXY_ALLOCATION_FAILED",
733
+ `${SMARTPROXY_APP_KEY_ENV} is not configured.`,
734
+ { vendor: "smartproxy" },
735
+ );
736
+ }
737
+ const lifetimeMinutes = resolveSmartproxyLifetime(policy);
738
+ const allocated = await allocateSmartproxy(
739
+ policy,
740
+ appKey,
741
+ lifetimeMinutes,
742
+ options.affinityKey,
743
+ context.protocol,
744
+ );
745
+ options.telemetry?.recordProxyResolution({ ...allocated.telemetry, protocol: context.protocol });
746
+ const poolIndex = selectProxyPoolIndex(allocated.pool.urls.length, context.poolIndex);
747
+ const url = allocated.pool.urls[poolIndex];
748
+ if (url) assertTunnelingScheme(url);
749
+ return {
750
+ shouldWarn: false,
751
+ url,
752
+ source: "smartproxy-allocator",
753
+ protocol: context.protocol,
754
+ diagnostics: {
755
+ ...allocated.pool.diagnostics,
756
+ poolSize: allocated.pool.urls.length,
757
+ poolIndex,
758
+ },
759
+ };
534
760
  }
535
761
 
536
762
  function resolvePolicy(options: ProxyResolutionOptions): ProviderProxyPolicy | undefined {
@@ -544,10 +770,198 @@ function resolvePolicy(options: ProxyResolutionOptions): ProviderProxyPolicy | u
544
770
  return undefined;
545
771
  }
546
772
 
547
- function resolveProxyProvider(policy: ProviderProxyPolicy): string {
548
- return (
549
- policy.provider ?? process.env[DEFAULT_PROXY_PROVIDER_ENV]?.trim().toLowerCase() ?? "custom"
550
- );
773
+ function isRegistryVendor(name: string | undefined): name is ProxyVendorName {
774
+ return name === "smartproxy" || name === "nodemaven";
775
+ }
776
+
777
+ /**
778
+ * Ordered list of SDK-native proxy vendors declared by the policy. `providers`
779
+ * takes precedence over the legacy singular `provider`; the platform default
780
+ * env is the final fallback. Non-registry names (decodo/custom) are dropped so
781
+ * an all-static chain falls through to the legacy env-URL path unchanged.
782
+ */
783
+ export function resolveVendorChain(policy: ProviderProxyPolicy): ProxyVendorName[] {
784
+ const declared: (ProviderProxyProvider | undefined)[] = policy.providers?.length
785
+ ? policy.providers
786
+ : [policy.provider ?? envDefaultProvider()];
787
+ const chain: ProxyVendorName[] = [];
788
+ for (const name of declared) {
789
+ if (isRegistryVendor(name) && !chain.includes(name)) {
790
+ chain.push(name);
791
+ }
792
+ }
793
+ return chain;
794
+ }
795
+
796
+ function envDefaultProvider(): ProviderProxyProvider | undefined {
797
+ const raw = process.env[DEFAULT_PROXY_PROVIDER_ENV]?.trim().toLowerCase();
798
+ return (raw as ProviderProxyProvider | undefined) ?? undefined;
799
+ }
800
+
801
+ function vendorHasCredentials(vendor: ProxyVendorName): boolean {
802
+ if (vendor === "nodemaven") return hasNodemavenCredentials();
803
+ return Boolean(process.env[SMARTPROXY_APP_KEY_ENV]?.trim());
804
+ }
805
+
806
+ function missingCredentialEnv(vendor: ProxyVendorName): string {
807
+ return vendor === "nodemaven" ? "APIFUSE__PROXY__NODEMAVEN_USERNAME" : SMARTPROXY_APP_KEY_ENV;
808
+ }
809
+
810
+ function vendorPoolSize(vendor: ProxyVendorName, policy: ProviderProxyPolicy): number {
811
+ return vendor === "nodemaven" ? nodemavenPoolSize(policy) : resolveSmartproxyPoolSize(policy);
812
+ }
813
+
814
+ /**
815
+ * Total attempt span across a policy's vendor chain — the sum of each vendor's
816
+ * pool size. Transports use this so successive attempts rotate a vendor's pool
817
+ * and then fail over to the next vendor via the flat attempt index. With one
818
+ * vendor this equals that vendor's pool size (today's behaviour).
819
+ */
820
+ export function resolvePolicyProxyPoolSpan(policy: ProviderProxyPolicy): number {
821
+ const chain = resolveVendorChain(policy);
822
+ if (chain.length === 0) return resolveSmartproxyPoolSize(policy);
823
+ return chain.reduce((sum, vendor) => sum + vendorPoolSize(vendor, policy), 0);
824
+ }
825
+
826
+ function vendorMaxPoolSize(vendor: ProxyVendorName): number {
827
+ return vendor === "nodemaven" ? NODEMAVEN_MAX_POOL_SIZE : SMARTPROXY_MAX_POOL_SIZE;
828
+ }
829
+
830
+ /**
831
+ * Absolute upper bound on a chain's attempt span — the sum of each vendor's
832
+ * *maximum* pool size. Unlike `resolvePolicyProxyPoolSpan` (the configured
833
+ * span), this backstop is independent of `session.poolSize`, so it never
834
+ * truncates a legitimately large pool below the point where the flat attempt
835
+ * index would cross into the next vendor (e.g. a 50-slot NodeMaven pool).
836
+ */
837
+ export function maxPolicyProxyPoolSpan(policy: ProviderProxyPolicy): number {
838
+ const chain = resolveVendorChain(policy);
839
+ if (chain.length === 0) return SMARTPROXY_MAX_POOL_SIZE;
840
+ return chain.reduce((sum, vendor) => sum + vendorMaxPoolSize(vendor), 0);
841
+ }
842
+
843
+ const UNSAFE_TRANSPORT_RETRY_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE", "TRACE"]);
844
+
845
+ /**
846
+ * Transport-retry attempt cap for a policy-managed request. A transport failure
847
+ * rotates the flat attempt index onto the *next* endpoint (and, once the index
848
+ * passes the primary vendor's pool span, the *next vendor*), so the cap must be
849
+ * the chain's full pool span for failover to reach the fallback vendor — the
850
+ * per-endpoint retry budget (default 3) never gets there.
851
+ *
852
+ * The span only widens beyond the caller's retry budget when ALL hold:
853
+ * - the request is policy-allocator managed (not a caller-supplied proxy URL);
854
+ * - the caller did NOT pin an explicit retry policy — `HttpRetryOptions.attempts`
855
+ * is the documented total-attempt ceiling and must be honoured verbatim;
856
+ * - the method is safe/idempotent — an unsafe request must never be duplicated
857
+ * across the pool even if some framework default would allow it;
858
+ * - the policy resolves a non-empty *registry* vendor chain (smartproxy /
859
+ * nodemaven). Static vendors (custom / decodo) and credential-less policies
860
+ * resolve no allocator pool, so every attempt would hit the same endpoint
861
+ * with no possible crossover — they keep the retry budget.
862
+ *
863
+ * The widened cap is bounded by the chain's true maximum span (sum of each
864
+ * vendor's max pool size), so a large NodeMaven pool (≤50) stays reachable and
865
+ * a pathological chain can never spin unbounded.
866
+ */
867
+ /**
868
+ * True when a policy request is in *implicit chain-rotation* mode: successive
869
+ * transport attempts rotate the flat index across the concatenated vendor pool
870
+ * spans (and, past the primary vendor's span, into the fallback vendor). This is
871
+ * the ONLY mode in which the transport loop widens its attempt cap AND
872
+ * de-duplicates repeated endpoints — the two behaviours must share one predicate
873
+ * so they never diverge. It holds when ALL of the widening conditions hold:
874
+ * - the request is policy-allocator managed (not a caller-supplied proxy URL);
875
+ * - the caller did NOT pin an explicit retry policy — its `attempts` ceiling is
876
+ * the documented contract and must be honoured verbatim against whatever
877
+ * endpoint each attempt resolves (even a repeated one), so no de-duplication;
878
+ * - the method is safe/idempotent — an unsafe request is never duplicated;
879
+ * - the policy resolves a non-empty registry vendor chain (smartproxy /
880
+ * nodemaven). Static vendors (custom / decodo) resolve the same URL every
881
+ * attempt, so there is nothing to rotate or de-duplicate.
882
+ */
883
+ export function policyRotatesTransportVendorChain(input: {
884
+ policy: ProviderProxyPolicy | undefined;
885
+ usesPolicyAllocator: boolean;
886
+ explicitRetry: boolean;
887
+ method: string;
888
+ }): boolean {
889
+ if (!input.usesPolicyAllocator || !input.policy || input.explicitRetry) {
890
+ return false;
891
+ }
892
+ if (UNSAFE_TRANSPORT_RETRY_METHODS.has(input.method.toUpperCase())) {
893
+ return false;
894
+ }
895
+ return resolveVendorChain(input.policy).length > 0;
896
+ }
897
+
898
+ export function resolvePolicyTransportAttemptCap(input: {
899
+ policy: ProviderProxyPolicy | undefined;
900
+ usesPolicyAllocator: boolean;
901
+ retryAttempts: number;
902
+ explicitRetry: boolean;
903
+ method: string;
904
+ }): number {
905
+ const budget = Math.max(1, Math.floor(input.retryAttempts));
906
+ if (
907
+ !policyRotatesTransportVendorChain({
908
+ policy: input.policy,
909
+ usesPolicyAllocator: input.usesPolicyAllocator,
910
+ explicitRetry: input.explicitRetry,
911
+ method: input.method,
912
+ })
913
+ ) {
914
+ return budget;
915
+ }
916
+ const span = Math.min(maxPolicyProxyPoolSpan(input.policy as ProviderProxyPolicy), resolvePolicyProxyPoolSpan(input.policy as ProviderProxyPolicy));
917
+ return Math.max(budget, span);
918
+ }
919
+
920
+ /**
921
+ * A registry vendor chain (smartproxy/nodemaven) resolves a potentially
922
+ * *different* endpoint per flat attempt index, so a transport retry should
923
+ * advance across endpoints and de-duplicate once the chain stops yielding new
924
+ * ones. Static/custom/decodo policies (empty registry chain) resolve the *same*
925
+ * URL every attempt by design — retrying that same endpoint is intended, so the
926
+ * transport loop must not de-duplicate them.
927
+ */
928
+ export function policyResolvesRegistryVendorChain(
929
+ policy: ProviderProxyPolicy | undefined,
930
+ ): boolean {
931
+ return Boolean(policy) && resolveVendorChain(policy as ProviderProxyPolicy).length > 0;
932
+ }
933
+
934
+ /** Map a resolved proxy source label to the vendor that served it. */
935
+ export function vendorFromResolvedSource(
936
+ source: ResolvedProxyConfig["source"],
937
+ ): ProxyVendorName | undefined {
938
+ if (source === "nodemaven-gateway") return "nodemaven";
939
+ if (source === "smartproxy-allocator") return "smartproxy";
940
+ return undefined;
941
+ }
942
+
943
+ function normalizeAttemptIndex(attempt: number | undefined): number {
944
+ return Number.isFinite(attempt) ? Math.max(0, Math.floor(attempt as number)) : 0;
945
+ }
946
+
947
+ /**
948
+ * Map a flat attempt index into (vendorIndex, poolIndex) by concatenating each
949
+ * vendor's pool space in chain order. With a single vendor this reduces to
950
+ * `attempt % poolSize`, preserving today's behaviour exactly.
951
+ */
952
+ export function mapFlatAttempt(
953
+ flat: number,
954
+ sizes: readonly number[],
955
+ ): { vendorIndex: number; poolIndex: number } {
956
+ let cursor = flat;
957
+ for (let vendorIndex = 0; vendorIndex < sizes.length; vendorIndex++) {
958
+ const size = Math.max(1, sizes[vendorIndex] ?? 1);
959
+ if (cursor < size) {
960
+ return { vendorIndex, poolIndex: cursor };
961
+ }
962
+ cursor -= size;
963
+ }
964
+ return { vendorIndex: 0, poolIndex: 0 };
551
965
  }
552
966
 
553
967
  function resolveSmartproxyCountry(policy: ProviderProxyPolicy): string | undefined {
@@ -591,10 +1005,12 @@ function buildSmartproxyCacheKey(
591
1005
  policy: ProviderProxyPolicy,
592
1006
  affinityKey: string | undefined,
593
1007
  lifetimeMinutes: number,
1008
+ protocol: ProxyProtocol,
594
1009
  ): string {
595
1010
  const poolSize = resolveSmartproxyPoolSize(policy);
596
1011
  return JSON.stringify({
597
1012
  provider: "smartproxy",
1013
+ protocol,
598
1014
  country: resolveSmartproxyCountry(policy),
599
1015
  affinity: policy.session?.affinity ?? "request",
600
1016
  affinityKey: (policy.session?.affinity ?? "request") === "request" ? undefined : affinityKey,
@@ -608,8 +1024,9 @@ async function allocateSmartproxy(
608
1024
  appKey: string,
609
1025
  lifetimeMinutes: number,
610
1026
  affinityKey: string | undefined,
1027
+ protocol: ProxyProtocol,
611
1028
  ): Promise<SmartproxyAllocationResult> {
612
- const cacheKey = buildSmartproxyCacheKey(policy, affinityKey, lifetimeMinutes);
1029
+ const cacheKey = buildSmartproxyCacheKey(policy, affinityKey, lifetimeMinutes, protocol);
613
1030
  const startedAt = Date.now();
614
1031
  const now = startedAt;
615
1032
  const invalidatedUntil = invalidatedProxyKeys.get(cacheKey) ?? 0;
@@ -617,7 +1034,7 @@ async function allocateSmartproxy(
617
1034
  const cached = proxyCache.get(cacheKey);
618
1035
  if (!skipCached && cached && isFresh(cached, now)) {
619
1036
  if (shouldSoftRefresh(cached, now)) {
620
- void refreshSmartproxyPool(cacheKey, policy, appKey, lifetimeMinutes);
1037
+ void refreshSmartproxyPool(cacheKey, policy, appKey, lifetimeMinutes, protocol);
621
1038
  return {
622
1039
  pool: cached,
623
1040
  telemetry: telemetryForPool(cached, "soft_stale_refresh", startedAt, {
@@ -653,6 +1070,7 @@ async function allocateSmartproxy(
653
1070
  appKey,
654
1071
  lifetimeMinutes,
655
1072
  startedAt,
1073
+ protocol,
656
1074
  ).finally(() => {
657
1075
  proxyInflight.delete(cacheKey);
658
1076
  });
@@ -691,11 +1109,20 @@ async function refreshSmartproxyPool(
691
1109
  policy: ProviderProxyPolicy,
692
1110
  appKey: string,
693
1111
  lifetimeMinutes: number,
1112
+ protocol: ProxyProtocol,
694
1113
  ): Promise<void> {
695
1114
  try {
696
- await allocateSmartproxyShared(cacheKey, policy, appKey, lifetimeMinutes, Date.now(), {
697
- background: true,
698
- });
1115
+ await allocateSmartproxyShared(
1116
+ cacheKey,
1117
+ policy,
1118
+ appKey,
1119
+ lifetimeMinutes,
1120
+ Date.now(),
1121
+ protocol,
1122
+ {
1123
+ background: true,
1124
+ },
1125
+ );
699
1126
  } catch {
700
1127
  // Soft refresh is opportunistic; current fresh pool remains usable.
701
1128
  }
@@ -707,6 +1134,7 @@ async function allocateSmartproxyShared(
707
1134
  appKey: string,
708
1135
  lifetimeMinutes: number,
709
1136
  startedAt: number,
1137
+ protocol: ProxyProtocol,
710
1138
  options: { background?: boolean } = {},
711
1139
  ): Promise<SmartproxyAllocationResult> {
712
1140
  const redis = getProxyRedis();
@@ -717,7 +1145,7 @@ async function allocateSmartproxyShared(
717
1145
  appKey,
718
1146
  lifetimeMinutes,
719
1147
  startedAt,
720
- { cacheStatus: "allocator" },
1148
+ { cacheStatus: "allocator", protocol },
721
1149
  );
722
1150
  }
723
1151
 
@@ -742,6 +1170,7 @@ async function allocateSmartproxyShared(
742
1170
  cacheStatus: options.background ? "soft_stale_refresh" : "allocator",
743
1171
  redis,
744
1172
  poolKey,
1173
+ protocol,
745
1174
  },
746
1175
  );
747
1176
  } finally {
@@ -897,10 +1326,17 @@ async function allocateAndStoreSmartproxyPool(
897
1326
  cacheStatus: ProxyCacheStatus;
898
1327
  redis?: ProxyRedisClient;
899
1328
  poolKey?: string;
1329
+ protocol: ProxyProtocol;
900
1330
  },
901
1331
  ): Promise<SmartproxyAllocationResult> {
902
1332
  const poolSize = resolveSmartproxyPoolSize(policy);
903
- const allocatorUrl = buildSmartproxyAllocatorUrl(policy, appKey, lifetimeMinutes, poolSize);
1333
+ const allocatorUrl = buildSmartproxyAllocatorUrl(
1334
+ policy,
1335
+ appKey,
1336
+ lifetimeMinutes,
1337
+ poolSize,
1338
+ options.protocol,
1339
+ );
904
1340
  const allocatorStartedAt = Date.now();
905
1341
  const allocatorDeadlineAt = allocatorStartedAt + smartproxyAllocatorDeadlineMs();
906
1342
  let allocation: SmartproxyAllocatorSuccess | undefined;
@@ -914,6 +1350,7 @@ async function allocateAndStoreSmartproxyPool(
914
1350
  allocatorUrl,
915
1351
  attempt,
916
1352
  allocatorDeadlineAt,
1353
+ options.protocol,
917
1354
  );
918
1355
  if (attemptResult.ok) {
919
1356
  allocation = attemptResult;
@@ -1034,6 +1471,7 @@ async function fetchSmartproxyAllocatorAttempt(
1034
1471
  allocatorUrl: string,
1035
1472
  attempt: number,
1036
1473
  deadlineAt: number,
1474
+ protocol: ProxyProtocol,
1037
1475
  ): Promise<SmartproxyAllocatorAttemptResult> {
1038
1476
  const { controller, dispose } = createDeadlineAbortController(deadlineAt);
1039
1477
  let response: Response;
@@ -1077,7 +1515,7 @@ async function fetchSmartproxyAllocatorAttempt(
1077
1515
  };
1078
1516
  }
1079
1517
 
1080
- const urls = parseSmartproxyAllocatorProxies(body);
1518
+ const urls = parseSmartproxyAllocatorProxies(body, protocol);
1081
1519
  const bodyClass = classifySmartproxyAllocatorBody(body, urls);
1082
1520
  if (urls.length === 0) {
1083
1521
  return {
@@ -1117,18 +1555,27 @@ function smartproxyAllocatorFailureMessage(
1117
1555
  return "Smartproxy allocator response did not contain a usable proxy endpoint.";
1118
1556
  }
1119
1557
 
1558
+ // Smartproxy get-ip-v3 `protocol` param: 1 = HTTP. The SOCKS5 value ("2") is a
1559
+ // best-effort mapping pending live vendor confirmation; http is the default and
1560
+ // the only value exercised in production today.
1561
+ const SMARTPROXY_PROTOCOL_PARAM: Record<ProxyProtocol, string> = {
1562
+ http: "1",
1563
+ socks5: "2",
1564
+ };
1565
+
1120
1566
  function buildSmartproxyAllocatorUrl(
1121
1567
  policy: ProviderProxyPolicy,
1122
1568
  appKey: string,
1123
1569
  lifetimeMinutes: number,
1124
1570
  poolSize: number,
1571
+ protocol: ProxyProtocol,
1125
1572
  ): string {
1126
1573
  const params = new URLSearchParams({
1127
1574
  app_key: appKey,
1128
1575
  pt: "9",
1129
1576
  num: String(poolSize),
1130
1577
  life: String(lifetimeMinutes),
1131
- protocol: "1",
1578
+ protocol: SMARTPROXY_PROTOCOL_PARAM[protocol],
1132
1579
  format: "txt",
1133
1580
  lb: "\\n",
1134
1581
  });
@@ -1141,7 +1588,8 @@ function buildSmartproxyAllocatorUrl(
1141
1588
  return `https://api.smartproxy.org/web_v1/ip/get-ip-v3?${params.toString()}`;
1142
1589
  }
1143
1590
 
1144
- function parseSmartproxyAllocatorProxies(body: string): string[] {
1591
+ function parseSmartproxyAllocatorProxies(body: string, protocol: ProxyProtocol): string[] {
1592
+ const scheme = protocol === "socks5" ? "socks5" : "http";
1145
1593
  const trimmed = body.trim();
1146
1594
  if (!trimmed) {
1147
1595
  return [];
@@ -1162,7 +1610,7 @@ function parseSmartproxyAllocatorProxies(body: string): string[] {
1162
1610
  "port" in item && (typeof item.port === "string" || typeof item.port === "number")
1163
1611
  ? item.port
1164
1612
  : "";
1165
- return ip && port ? `http://${ip}:${port}` : null;
1613
+ return ip && port ? `${scheme}://${ip}:${port}` : null;
1166
1614
  })
1167
1615
  .filter((url): url is string => url !== null);
1168
1616
  }
@@ -1174,7 +1622,7 @@ function parseSmartproxyAllocatorProxies(body: string): string[] {
1174
1622
  .split(/\r?\n/)
1175
1623
  .map((item) => item.trim())
1176
1624
  .filter((item) => /^\d{1,3}(?:\.\d{1,3}){3}:\d{2,5}$/.test(item))
1177
- .map((line) => `http://${line}`);
1625
+ .map((line) => `${scheme}://${line}`);
1178
1626
  }
1179
1627
 
1180
1628
  function classifySmartproxyAllocatorBody(
@@ -1207,12 +1655,17 @@ function markSmartproxyCacheInvalidated(options: ProxyResolutionOptions = {}): s
1207
1655
  if (!policy || policy.mode === "disabled") {
1208
1656
  return undefined;
1209
1657
  }
1210
- if (resolveProxyProvider(policy) !== "smartproxy") {
1658
+ if (!resolveVendorChain(policy).includes("smartproxy")) {
1211
1659
  return undefined;
1212
1660
  }
1213
1661
 
1214
1662
  const lifetimeMinutes = resolveSmartproxyLifetime(policy);
1215
- const cacheKey = buildSmartproxyCacheKey(policy, options.affinityKey, lifetimeMinutes);
1663
+ const cacheKey = buildSmartproxyCacheKey(
1664
+ policy,
1665
+ options.affinityKey,
1666
+ lifetimeMinutes,
1667
+ options.protocol ?? VENDOR_DEFAULT_PROTOCOL.smartproxy,
1668
+ );
1216
1669
  invalidatedProxyKeys.set(cacheKey, Date.now() + SMARTPROXY_INVALIDATION_SKIP_REDIS_MS);
1217
1670
  proxyCache.delete(cacheKey);
1218
1671
  proxyInflight.delete(cacheKey);