@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.
@@ -1,8 +1,11 @@
1
1
  import type {
2
2
  ProxyAttemptTelemetryEvent,
3
3
  ProxyCacheStatus,
4
+ ProxyProtocol,
4
5
  ProxyResolutionTelemetryEvent,
5
6
  ProxyTelemetrySink,
7
+ ProxyVendorFailoverTelemetryEvent,
8
+ ProxyVendorName,
6
9
  SmartproxyAllocatorBodyClass,
7
10
  } from "../config/loader.js";
8
11
 
@@ -11,7 +14,8 @@ export const PROVIDER_TELEMETRY_HEADER = "X-ApiFuse-Provider-Telemetry";
11
14
  type ProviderTelemetryHeader = {
12
15
  v: 1;
13
16
  proxy?: {
14
- provider: "smartproxy";
17
+ provider: ProxyVendorName;
18
+ protocol?: ProxyProtocol;
15
19
  cacheStatus: ProxyCacheStatus;
16
20
  cacheHit: boolean;
17
21
  resolutionMs: number;
@@ -27,6 +31,10 @@ type ProviderTelemetryHeader = {
27
31
  attempts: number;
28
32
  refreshes?: number;
29
33
  attemptSamples?: CompactProxyAttemptSample[];
34
+ /** Distinct vendors attempted across the resolution chain, in order seen. */
35
+ vendors?: ProxyVendorName[];
36
+ /** Cross-vendor failover events (bounded). */
37
+ failovers?: CompactVendorFailoverSample[];
30
38
  };
31
39
  };
32
40
 
@@ -41,8 +49,17 @@ type CompactProxyAttemptSample = {
41
49
  d?: number;
42
50
  };
43
51
 
52
+ type CompactVendorFailoverSample = {
53
+ v: ProxyVendorName;
54
+ nx?: ProxyVendorName;
55
+ p: "resolution" | "transport";
56
+ r: ProxyVendorFailoverTelemetryEvent["reason"];
57
+ a?: number;
58
+ };
59
+
44
60
  const MAX_HEADER_BYTES = 4_096;
45
61
  const MAX_PROXY_ATTEMPT_SAMPLES = 24;
62
+ const MAX_PROXY_FAILOVER_SAMPLES = 12;
46
63
 
47
64
  const CACHE_STATUS_SEVERITY: Record<ProxyCacheStatus, number> = {
48
65
  disabled: 0,
@@ -76,10 +93,12 @@ function encodeBase64Url(value: string): string {
76
93
  export class ProxyTelemetryCollector implements ProxyTelemetrySink {
77
94
  #events: ProxyResolutionTelemetryEvent[] = [];
78
95
  #attempts: ProxyAttemptTelemetryEvent[] = [];
96
+ #failovers: ProxyVendorFailoverTelemetryEvent[] = [];
79
97
 
80
98
  recordProxyResolution(event: ProxyResolutionTelemetryEvent): void {
81
99
  this.#events.push({
82
- provider: "smartproxy",
100
+ provider: event.provider,
101
+ ...(event.protocol ? { protocol: event.protocol } : {}),
83
102
  cacheStatus: event.cacheStatus,
84
103
  cacheHit: event.cacheHit,
85
104
  resolutionMs: Math.max(0, Math.floor(event.resolutionMs)),
@@ -112,10 +131,21 @@ export class ProxyTelemetryCollector implements ProxyTelemetrySink {
112
131
  });
113
132
  }
114
133
 
134
+ recordProxyVendorFailover(event: ProxyVendorFailoverTelemetryEvent): void {
135
+ if (this.#failovers.length >= MAX_PROXY_FAILOVER_SAMPLES) return;
136
+ this.#failovers.push({
137
+ vendor: event.vendor,
138
+ ...(event.nextVendor ? { nextVendor: event.nextVendor } : {}),
139
+ phase: event.phase,
140
+ reason: event.reason,
141
+ ...(event.attempt === undefined ? {} : { attempt: Math.max(0, Math.floor(event.attempt)) }),
142
+ });
143
+ }
144
+
115
145
  recordProxyAttempt(event: ProxyAttemptTelemetryEvent): void {
116
146
  if (this.#attempts.length >= MAX_PROXY_ATTEMPT_SAMPLES) return;
117
147
  this.#attempts.push({
118
- provider: "smartproxy",
148
+ provider: event.provider,
119
149
  attempt: Math.max(1, Math.floor(event.attempt || 1)),
120
150
  ...(event.poolIndex === undefined
121
151
  ? {}
@@ -134,9 +164,17 @@ export class ProxyTelemetryCollector implements ProxyTelemetrySink {
134
164
  const [first, ...rest] = this.#events;
135
165
  if (!first) return undefined;
136
166
 
167
+ // The serving vendor/protocol is the last recorded resolution (a failed
168
+ // vendor records first, the vendor that served records last).
169
+ const serving = this.#events[this.#events.length - 1] ?? first;
170
+ const vendors: ProxyVendorName[] = [];
171
+ for (const event of this.#events) {
172
+ if (!vendors.includes(event.provider)) vendors.push(event.provider);
173
+ }
174
+
137
175
  const aggregate = rest.reduce<ProxyResolutionTelemetryEvent>(
138
176
  (acc, event) => ({
139
- provider: "smartproxy",
177
+ provider: event.provider,
140
178
  cacheStatus: worseStatus(acc.cacheStatus, event.cacheStatus),
141
179
  cacheHit: acc.cacheHit && event.cacheHit,
142
180
  resolutionMs: acc.resolutionMs + event.resolutionMs,
@@ -157,7 +195,8 @@ export class ProxyTelemetryCollector implements ProxyTelemetrySink {
157
195
  const payload: ProviderTelemetryHeader = {
158
196
  v: 1,
159
197
  proxy: {
160
- provider: "smartproxy",
198
+ provider: serving.provider,
199
+ ...(serving.protocol ? { protocol: serving.protocol } : {}),
161
200
  cacheStatus: aggregate.cacheStatus,
162
201
  cacheHit: aggregate.cacheHit,
163
202
  resolutionMs: aggregate.resolutionMs,
@@ -194,6 +233,18 @@ export class ProxyTelemetryCollector implements ProxyTelemetrySink {
194
233
  })),
195
234
  }
196
235
  : {}),
236
+ ...(vendors.length > 1 ? { vendors } : {}),
237
+ ...(this.#failovers.length > 0
238
+ ? {
239
+ failovers: this.#failovers.map((failover) => ({
240
+ v: failover.vendor,
241
+ ...(failover.nextVendor ? { nx: failover.nextVendor } : {}),
242
+ p: failover.phase,
243
+ r: failover.reason,
244
+ ...(failover.attempt === undefined ? {} : { a: failover.attempt }),
245
+ })),
246
+ }
247
+ : {}),
197
248
  },
198
249
  };
199
250
 
@@ -115,7 +115,17 @@ function envelopeFromJson(
115
115
  // biome-ignore lint/suspicious/noExplicitAny: state envelopes deserialize caller-owned generic values.
116
116
  ): StateValue<any> | null {
117
117
  if (!raw) return null;
118
- const parsed: unknown = JSON.parse(raw);
118
+ // A corrupt/undecodable persisted envelope must be treated as absent rather
119
+ // than throwing a raw JSON.parse SyntaxError: an uncaught SyntaxError escapes
120
+ // the provider error taxonomy, is masked as internal_error 500, and is then
121
+ // retried by the hub (2026-07-22 catchtable reserve RCA, candidate A). Returning
122
+ // null also keeps list() from aborting the whole scan on a single bad entry.
123
+ let parsed: unknown;
124
+ try {
125
+ parsed = JSON.parse(raw);
126
+ } catch {
127
+ return null;
128
+ }
119
129
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
120
130
  return null;
121
131
  }
@@ -2,13 +2,16 @@ import { createHash } from "node:crypto";
2
2
  import type { Browser, ImpitOptions, ImpitResponse, RequestInit } from "impit";
3
3
  import { Impit } from "impit";
4
4
 
5
- import type { ProxyResolutionOptions } from "../config/loader.js";
5
+ import type { ProxyResolutionOptions, ProxyVendorName } from "../config/loader.js";
6
6
  import {
7
7
  DEFAULT_SMARTPROXY_POOL_SIZE,
8
8
  invalidateProxyResolutionCacheAsync,
9
+ policyResolvesRegistryVendorChain,
9
10
  ProxyResolutionError,
11
+ resolvePolicyProxyPoolSpan,
12
+ resolvePolicyTransportAttemptCap,
10
13
  resolveProxyConfigAsync,
11
- SMARTPROXY_MAX_POOL_SIZE,
14
+ vendorFromResolvedSource,
12
15
  } from "../config/loader.js";
13
16
  import { SDKError, TransportError } from "../errors.js";
14
17
  import { getStealthProfile } from "../stealth/profiles.js";
@@ -51,7 +54,6 @@ const DEFAULT_PROFILE = "chrome-146";
51
54
  const MISSING_PROXY_WARNING =
52
55
  "[provider-sdk] Provider requested proxy routing, but no proxy URL was configured. Continuing without proxy.";
53
56
 
54
- const MAX_POLICY_PROXY_RETRY_ATTEMPTS = SMARTPROXY_MAX_POOL_SIZE;
55
57
  const MAX_POLICY_PROXY_POOL_REFRESHES = 1;
56
58
  const PROXY_CONNECT_FAILURE_CODE = "proxy_connect_failed";
57
59
  const PROXY_CONNECT_FAILURE_BODY_PATTERN =
@@ -426,6 +428,7 @@ type ResolvedAttemptProxy = {
426
428
  url?: string;
427
429
  poolIndex?: number;
428
430
  proxyHash?: string;
431
+ vendor?: ProxyVendorName;
429
432
  };
430
433
 
431
434
  function proxyPoolIndexFromDiagnostics(
@@ -615,6 +618,7 @@ function createSessionFetcher(
615
618
  async function resolveRequestProxy(
616
619
  options?: StealthFetchOptions,
617
620
  proxyAttempt?: number,
621
+ refreshEpoch?: number,
618
622
  ): Promise<ResolvedAttemptProxy> {
619
623
  const resolvedProxy = await resolveProxyConfigAsync({
620
624
  proxy: options?.proxy ?? clientOptions.proxy,
@@ -626,6 +630,10 @@ function createSessionFetcher(
626
630
  proxyAttemptOffset: options?.proxyAttemptOffset,
627
631
  retryAttemptOffset: proxyAttempt,
628
632
  }),
633
+ // The impit stealth transport tunnels both HTTP CONNECT and SOCKS5,
634
+ // preserving the client TLS fingerprint end-to-end.
635
+ transportProtocols: ["http", "socks5"],
636
+ ...(refreshEpoch === undefined ? {} : { proxyRefreshEpoch: refreshEpoch }),
629
637
  telemetry: clientOptions.telemetry,
630
638
  });
631
639
 
@@ -638,6 +646,7 @@ function createSessionFetcher(
638
646
  url: resolvedProxy.url,
639
647
  poolIndex: proxyPoolIndexFromDiagnostics(resolvedProxy.diagnostics),
640
648
  proxyHash: proxyEndpointHash(resolvedProxy.url),
649
+ vendor: vendorFromResolvedSource(resolvedProxy.source),
641
650
  };
642
651
  }
643
652
 
@@ -662,18 +671,33 @@ function createSessionFetcher(
662
671
  const hasPolicyProxy = isPolicyManagedProxy(clientOptions);
663
672
  const usesPolicyAllocator = hasPolicyProxy && !options.proxy && !clientOptions.proxy;
664
673
  const retryAttemptCap = Math.max(1, stealthRetryOptions?.attempts ?? 1);
674
+ // Span the whole vendor chain: successive attempts rotate one vendor's
675
+ // pool, then fail over to the next vendor via the flat attempt index.
676
+ const policyProxy =
677
+ clientOptions.proxyPolicy ??
678
+ (typeof clientOptions.upstream?.proxy === "object"
679
+ ? clientOptions.upstream.proxy
680
+ : undefined);
681
+ // The pool span is already bounded by each vendor's max pool size
682
+ // (smartproxy ≤20, nodemaven ≤50), so the configured span never exceeds
683
+ // the chain's true maximum — a large NodeMaven pool stays fully
684
+ // reachable rather than being truncated at an arbitrary ceiling.
665
685
  const policyProxyAttemptCap = Math.max(
666
686
  1,
667
- Math.min(
668
- MAX_POLICY_PROXY_RETRY_ATTEMPTS,
669
- clientOptions.proxyPolicy?.session?.poolSize ??
670
- (typeof clientOptions.upstream?.proxy === "object"
671
- ? clientOptions.upstream.proxy.session?.poolSize
672
- : undefined) ??
673
- DEFAULT_SMARTPROXY_POOL_SIZE,
674
- ),
687
+ policyProxy ? resolvePolicyProxyPoolSpan(policyProxy) : DEFAULT_SMARTPROXY_POOL_SIZE,
675
688
  );
676
- const maxAttempts = usesPolicyAllocator ? policyProxyAttemptCap : retryAttemptCap;
689
+ // A registry vendor chain (smartproxy/nodemaven) is the only policy whose
690
+ // successive attempts resolve a *different* endpoint, so it is the only one
691
+ // that may widen the attempt cap to the pool span, de-duplicate endpoints,
692
+ // and drive allocator stale-pool refresh. A static custom/decodo policy
693
+ // resolves the same URL every attempt: widening/refreshing it would resend
694
+ // the request dozens of times (up to maxAttempts × refreshes) and bypass
695
+ // retry:false and unsafe-method controls. Static policies therefore follow
696
+ // the ordinary transport-retry budget instead.
697
+ const rotatesRegistryChain =
698
+ usesPolicyAllocator && policyResolvesRegistryVendorChain(policyProxy);
699
+ const maxAttempts = rotatesRegistryChain ? policyProxyAttemptCap : retryAttemptCap;
700
+ const dedupeAllocatorEndpoints = rotatesRegistryChain;
677
701
  let lastError: unknown;
678
702
 
679
703
  for (
@@ -698,7 +722,7 @@ function createSessionFetcher(
698
722
  if (attemptRecorded || !proxy) return;
699
723
  attemptRecorded = true;
700
724
  clientOptions.telemetry?.recordProxyAttempt?.({
701
- provider: "smartproxy",
725
+ provider: attemptProxy?.vendor ?? "smartproxy",
702
726
  attempt: attempt + 1,
703
727
  ...(attemptProxy?.poolIndex === undefined
704
728
  ? {}
@@ -712,11 +736,16 @@ function createSessionFetcher(
712
736
  };
713
737
  try {
714
738
  assertNoUnsupportedFingerprintOverrides(options);
715
- attemptProxy = await resolveRequestProxy(options, attempt);
739
+ attemptProxy = await resolveRequestProxy(options, attempt, refreshAttempt);
716
740
  proxy = attemptProxy.url;
717
- if (proxy && usesPolicyAllocator) {
741
+ if (proxy && dedupeAllocatorEndpoints) {
742
+ // An under-filled allocation repeats endpoints (via the modulo
743
+ // pool mapping) before the flat offset crosses into the next
744
+ // vendor. Skip an already-tried endpoint and advance the offset
745
+ // rather than breaking — breaking here would strand the request on
746
+ // the primary vendor and never reach the fallback leg.
718
747
  if (attemptedProxies.has(proxy)) {
719
- break;
748
+ continue;
720
749
  }
721
750
  attemptedProxies.add(proxy);
722
751
  }
@@ -796,7 +825,7 @@ function createSessionFetcher(
796
825
  proxyAttemptStatus(normalizedError),
797
826
  );
798
827
  lastError = normalizedError;
799
- if (proxy && usesPolicyAllocator && isProxyPoolRefreshableError(normalizedError)) {
828
+ if (proxy && rotatesRegistryChain && isProxyPoolRefreshableError(normalizedError)) {
800
829
  stalePoolError = normalizedError;
801
830
  if (shouldRunProxyAuthDiagnostic(normalizedError)) {
802
831
  stalePoolDiagnosticProxy = proxy;
@@ -806,11 +835,28 @@ function createSessionFetcher(
806
835
  }
807
836
  break;
808
837
  }
838
+ // Cap the number of transport retries. For a policy-allocator chain,
839
+ // every attempt resolves a *different* endpoint/vendor (poolIndex
840
+ // rotates across the concatenated vendor pool spans), so a transport
841
+ // failure is a signal to advance to the next endpoint — potentially
842
+ // crossing into the fallback vendor — not to retry the same endpoint.
843
+ // Truncating that rotation at the per-endpoint retry budget would
844
+ // strand the request on the primary vendor and never reach the
845
+ // fallback, since the crossover only happens once the flat attempt
846
+ // index exceeds the primary vendor's pool size (~10-20).
847
+ // resolvePolicyTransportAttemptCap widens to the full chain span only
848
+ // for implicit, safe-method allocator requests; explicit retry
849
+ // policies (their documented `attempts` ceiling), unsafe methods, and
850
+ // static/non-registry vendors keep the per-endpoint retry budget.
851
+ const transportRetryCap = resolvePolicyTransportAttemptCap({
852
+ policy: policyProxy,
853
+ usesPolicyAllocator,
854
+ retryAttempts: stealthRetryOptions?.attempts ?? 1,
855
+ explicitRetry: hasExplicitRetryPolicy,
856
+ method,
857
+ });
809
858
  if (
810
- attempt + 1 <
811
- (stealthRetryOptions
812
- ? Math.min(maxAttempts, stealthRetryOptions.attempts)
813
- : maxAttempts) &&
859
+ attempt + 1 < transportRetryCap &&
814
860
  shouldRetryProxyTransportAttempt({
815
861
  error: normalizedError,
816
862
  explicitRetry: hasExplicitRetryPolicy,
@@ -829,7 +875,7 @@ function createSessionFetcher(
829
875
  }
830
876
 
831
877
  if (
832
- usesPolicyAllocator &&
878
+ rotatesRegistryChain &&
833
879
  stalePoolError &&
834
880
  refreshAttempt < MAX_POLICY_PROXY_POOL_REFRESHES
835
881
  ) {
@@ -512,11 +512,22 @@ function toErrorResponse(error: unknown, requestId?: string): OperationErrorResp
512
512
  };
513
513
  }
514
514
 
515
+ // A masked internal error MUST NOT be advertised as retryable: without an
516
+ // explicit retryable:false the hub (bori provider-backed engine) defaults 5xx
517
+ // to retryable:true, which turns a deterministic pre-upstream crash into an
518
+ // infinite START->CONTINUE->restart loop (2026-07-22 catchtable reserve RCA).
519
+ // We still refuse to leak message/stack — only the error class name (or the
520
+ // primitive type for non-Error throwables) is surfaced for ops triage.
515
521
  return {
516
522
  error: {
517
523
  code: "internal_error",
518
524
  message: "Internal error",
519
525
  ...(requestId ? { requestId } : {}),
526
+ details: {
527
+ retryable: false,
528
+ category: "internal_error",
529
+ errorClass: error instanceof Error ? error.name : typeof error,
530
+ },
520
531
  },
521
532
  };
522
533
  }
package/src/types.ts CHANGED
@@ -769,7 +769,25 @@ export type ProviderAccessVisibility = "public" | "early_access";
769
769
 
770
770
  export type ProviderProxyMode = "disabled" | "optional" | "required";
771
771
 
772
- export type ProviderProxyProvider = "smartproxy" | "decodo" | "custom";
772
+ /**
773
+ * Proxy egress vendors. These are FOUR DISTINCT services — do not conflate them
774
+ * (a common mistake because the names collide with a well-known rebrand):
775
+ *
776
+ * - `smartproxy` — **api.smartproxy.org**, a residential proxy with an IP
777
+ * *extraction/allocation* API (app_key → a pool of raw `ip:port` CONNECT
778
+ * endpoints). This is our own vendor. It is NOT the company formerly named
779
+ * "Smartproxy". Credentials: `APIFUSE__PROXY__SMARTPROXY_APP_KEY`.
780
+ * - `nodemaven` — **gate.nodemaven.com**, a *gateway* proxy with static
781
+ * credentials; geo/session encoded in the username, no allocation API.
782
+ * - `decodo` — **decodo.com**, the *gateway* proxy that was named "Smartproxy"
783
+ * (smartproxy.com) before its 2025 rebrand to Decodo. Sticky sessions via
784
+ * username params. A different company from `smartproxy` above.
785
+ * **@deprecated** — unused; no managed adapter. Use `smartproxy`/`nodemaven`,
786
+ * or the `APIFUSE__PROXY__URL` bring-your-own escape hatch.
787
+ * - `custom` — **@deprecated** bring-your-own static proxy URL marker. The
788
+ * `APIFUSE__PROXY__URL` env still works without declaring this value.
789
+ */
790
+ export type ProviderProxyProvider = "smartproxy" | "nodemaven" | "decodo" | "custom";
773
791
 
774
792
  export type ProviderProxySessionAffinity =
775
793
  | "request"
@@ -783,7 +801,18 @@ export interface ProviderProxyPolicy {
783
801
  * certificate verification, and vendor allocator endpoints are SDK-owned.
784
802
  */
785
803
  mode: ProviderProxyMode;
804
+ /**
805
+ * @deprecated Use `providers: [...]` to declare an ordered vendor fallback
806
+ * chain. A single-element `providers` list is equivalent to this field.
807
+ */
786
808
  provider?: ProviderProxyProvider;
809
+ /**
810
+ * Ordered proxy-vendor fallback chain. The SDK tries each vendor in order and
811
+ * fails over to the next when a vendor lacks credentials or its allocation /
812
+ * transport is exhausted. When omitted, `provider` (or the platform default)
813
+ * is used as a single-vendor chain.
814
+ */
815
+ providers?: ProviderProxyProvider[];
787
816
  geo?: {
788
817
  /** ISO 3166-1 alpha-2 country code, for example KR or US. */
789
818
  country?: Iso3166Alpha2CountryCode;