@apifuse/provider-sdk 2.2.0-beta.1 → 2.2.0-beta.3

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,10 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { Impit } from "impit";
3
3
  import { DEFAULT_SMARTPROXY_POOL_SIZE, invalidateProxyResolutionCacheAsync, ProxyResolutionError, resolveProxyConfigAsync, SMARTPROXY_MAX_POOL_SIZE, } from "../config/loader";
4
- import { ProviderError, SDKError, TransportError } from "../errors";
4
+ import { SDKError, TransportError } from "../errors";
5
5
  import { getStealthProfile } from "../stealth/profiles";
6
- import { HttpRetryPreset, HttpRetryUnsafeMethodPolicy } from "../types";
7
- import { createProxyAuthIpDeniedError, createProxyEdgeAuthRejectedError, createProxyEdgeTlsRejectedError, createProxyPoolExhaustedError, createProxyPoolStaleError, isProxyAuthIpDeniedMessage, isProxyEdgeAuthRejectedMessage, isProxyEdgeTlsRejectedResponse, isProxyPoolRefreshableError, isProxyPoolStaleMessage, isProxyPoolStaleStatus, PROXY_AUTH_IP_DENIED_CODE, PROXY_EDGE_AUTH_REJECTED_CODE, PROXY_POOL_STALE_CODE, } from "./proxy-errors";
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";
7
+ import { computeProxyAttemptIndex, computeProxyTransportRetryDelayMs, createDefaultProxyTransportRetryOptions, normalizeProxyTransportRetryOptions, shouldRetryProxyTransportAttempt, validateUnsafeProxyTransportRetryMethods, } from "./proxy-retry-policy";
8
8
  import { appendQueryParams } from "./request-options";
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.";
@@ -14,31 +14,7 @@ const PROXY_CONNECT_FAILURE_CODE = "proxy_connect_failed";
14
14
  const PROXY_CONNECT_FAILURE_BODY_PATTERN = /\bproxy\b.*\b(non[\s-]?200|connect|tunnel)|\bconnect\b.*\bproxy\b|\btunnel\b/i;
15
15
  const PROXY_AUTH_DIAGNOSTIC_URL = "http://example.com/";
16
16
  const PROXY_AUTH_DIAGNOSTIC_TIMEOUT_MS = 5_000;
17
- const DEFAULT_STEALTH_RETRY_METHODS = ["GET", "HEAD", "OPTIONS"];
18
- const DEFAULT_STEALTH_RETRY_ERROR_CODES = [
19
- PROXY_CONNECT_FAILURE_CODE,
20
- "transport_network_error",
21
- "transport_timeout",
22
- ];
23
- const RATE_LIMIT_STEALTH_RETRY_ERROR_CODES = ["transport_timeout"];
24
- const KNOWN_STEALTH_RETRY_METHODS = new Set([
25
- "GET",
26
- "HEAD",
27
- "POST",
28
- "PUT",
29
- "DELETE",
30
- "OPTIONS",
31
- "TRACE",
32
- "PATCH",
33
- ]);
34
- const UNSAFE_STEALTH_RETRY_METHODS = new Set([
35
- "POST",
36
- "PUT",
37
- "PATCH",
38
- "DELETE",
39
- "TRACE",
40
- ]);
41
- const MAX_STEALTH_RETRY_ATTEMPTS = 8;
17
+ const STEALTH_PROXY_TRANSPORT_RETRY_ERROR_CODES = [PROXY_CONNECT_FAILURE_CODE];
42
18
  const REMOVED_CHROME_PROFILE_NAMES = new Set([
43
19
  "chrome-120",
44
20
  "chrome-124",
@@ -303,130 +279,8 @@ function isPolicyManagedProxy(options) {
303
279
  const policy = options.proxyPolicy ?? options.upstream?.proxy;
304
280
  return Boolean(policy && typeof policy === "object");
305
281
  }
306
- function isRetrySafeStealthMethod(method) {
307
- return method === "GET" || method === "HEAD" || method === "OPTIONS";
308
- }
309
- function createStealthRetryOptions(preset) {
310
- switch (preset) {
311
- case HttpRetryPreset.Off:
312
- return {
313
- attempts: 1,
314
- methods: DEFAULT_STEALTH_RETRY_METHODS,
315
- errorCodes: DEFAULT_STEALTH_RETRY_ERROR_CODES,
316
- unsafeMethodPolicy: HttpRetryUnsafeMethodPolicy.Reject,
317
- };
318
- case HttpRetryPreset.AggressiveRead:
319
- return {
320
- attempts: 4,
321
- methods: DEFAULT_STEALTH_RETRY_METHODS,
322
- errorCodes: DEFAULT_STEALTH_RETRY_ERROR_CODES,
323
- unsafeMethodPolicy: HttpRetryUnsafeMethodPolicy.Reject,
324
- };
325
- case HttpRetryPreset.RateLimitAware:
326
- return {
327
- attempts: 3,
328
- methods: DEFAULT_STEALTH_RETRY_METHODS,
329
- errorCodes: RATE_LIMIT_STEALTH_RETRY_ERROR_CODES,
330
- unsafeMethodPolicy: HttpRetryUnsafeMethodPolicy.Reject,
331
- };
332
- case HttpRetryPreset.SafeRead:
333
- case HttpRetryPreset.TransportTransient:
334
- return {
335
- attempts: 3,
336
- methods: DEFAULT_STEALTH_RETRY_METHODS,
337
- errorCodes: DEFAULT_STEALTH_RETRY_ERROR_CODES,
338
- unsafeMethodPolicy: HttpRetryUnsafeMethodPolicy.Reject,
339
- };
340
- }
341
- throw new ProviderError(`Unknown stealth retry preset: ${preset}`, {
342
- code: "retry_invalid_policy",
343
- });
344
- }
345
- function normalizeStealthRetryOptions(retry) {
346
- if (retry === undefined)
347
- return undefined;
348
- if (retry === false)
349
- return createStealthRetryOptions(HttpRetryPreset.Off);
350
- if (retry === true)
351
- return createStealthRetryOptions(HttpRetryPreset.TransportTransient);
352
- if (typeof retry === "string") {
353
- if (!Object.values(HttpRetryPreset).includes(retry)) {
354
- throw new ProviderError(`Unknown stealth retry preset: ${retry}`, {
355
- code: "retry_invalid_policy",
356
- });
357
- }
358
- return createStealthRetryOptions(retry);
359
- }
360
- if (typeof retry !== "object" || retry === null || Array.isArray(retry)) {
361
- throw new ProviderError("Stealth retry policy must be a plain object", {
362
- code: "retry_invalid_policy",
363
- });
364
- }
365
- if (retry.unsafeMethodPolicy !== undefined &&
366
- !Object.values(HttpRetryUnsafeMethodPolicy).includes(retry.unsafeMethodPolicy)) {
367
- throw new ProviderError(`Unknown stealth retry unsafe method policy: ${String(retry.unsafeMethodPolicy)}`, { code: "retry_invalid_policy" });
368
- }
369
- if (retry.methods !== undefined) {
370
- if (!Array.isArray(retry.methods)) {
371
- throw new ProviderError("Stealth retry methods must be an array", {
372
- code: "retry_invalid_policy",
373
- });
374
- }
375
- const unknownMethods = retry.methods
376
- .map((method) => (typeof method === "string" ? method.toUpperCase() : ""))
377
- .filter((method) => !KNOWN_STEALTH_RETRY_METHODS.has(method));
378
- if (unknownMethods.length > 0) {
379
- throw new ProviderError(`Unknown stealth retry method(s): ${unknownMethods.join(", ")}`, { code: "retry_invalid_policy" });
380
- }
381
- }
382
- if (retry.errorCodes !== undefined) {
383
- if (!Array.isArray(retry.errorCodes) ||
384
- retry.errorCodes.some((errorCode) => typeof errorCode !== "string")) {
385
- throw new ProviderError("Stealth retry errorCodes must contain only strings", { code: "retry_invalid_policy" });
386
- }
387
- }
388
- const base = createStealthRetryOptions(retry.preset ?? HttpRetryPreset.TransportTransient);
389
- const attempts = retry.attempts === undefined || !Number.isFinite(retry.attempts)
390
- ? base.attempts
391
- : Math.max(1, Math.min(MAX_STEALTH_RETRY_ATTEMPTS, Math.floor(retry.attempts)));
392
- const normalized = {
393
- attempts,
394
- methods: retry.methods?.map((method) => method.toUpperCase()) ?? base.methods,
395
- errorCodes: retry.errorCodes ?? base.errorCodes,
396
- unsafeMethodPolicy: retry.unsafeMethodPolicy ?? base.unsafeMethodPolicy,
397
- };
398
- if (normalized.unsafeMethodPolicy !==
399
- HttpRetryUnsafeMethodPolicy.AllowExplicitUnsafe) {
400
- const unsafeMethods = normalized.methods.filter((method) => UNSAFE_STEALTH_RETRY_METHODS.has(method.toUpperCase()));
401
- if (unsafeMethods.length > 0) {
402
- throw new ProviderError(`Stealth retry methods include unsafe method(s): ${unsafeMethods.join(", ")}`, { code: "retry_unsafe_method" });
403
- }
404
- }
405
- return normalized;
406
- }
407
- function isExplicitStealthRetryAllowed(method, error, retryOptions) {
408
- if (!retryOptions || retryOptions.attempts <= 1)
409
- return false;
410
- return (retryOptions.methods.includes(method.toUpperCase()) &&
411
- retryOptions.errorCodes.includes(proxyAttemptErrorCode(error)));
412
- }
413
- function isRetryableProxyTransportError(error) {
414
- if (error instanceof TransportError) {
415
- if (error.code === PROXY_AUTH_IP_DENIED_CODE) {
416
- return false;
417
- }
418
- return (error.code === PROXY_CONNECT_FAILURE_CODE ||
419
- error.code === "transport_network_error" ||
420
- error.code === "transport_timeout");
421
- }
422
- if (error instanceof SDKError) {
423
- return false;
424
- }
425
- const message = error instanceof Error ? error.message : String(error);
426
- return /\bproxy\b|\bnon[\s-]?200\b|\bconnect\b|\btunnel\b/i.test(message);
427
- }
428
282
  function isProxyConnectFailureResponse(response, body) {
429
- return (response.status === 0 && PROXY_CONNECT_FAILURE_BODY_PATTERN.test(body ?? ""));
283
+ return response.status === 0 && PROXY_CONNECT_FAILURE_BODY_PATTERN.test(body ?? "");
430
284
  }
431
285
  function createProxyConnectFailureError(body, cause) {
432
286
  const bodyExcerpt = (body ?? "").trim().slice(0, 1_000);
@@ -529,8 +383,7 @@ function normalizeStealthTransportError(error) {
529
383
  return createProxyEdgeAuthRejectedError(error instanceof Error ? error : undefined);
530
384
  }
531
385
  const proxyTunnelStatus = getProxyTunnelStatus(error);
532
- if (proxyTunnelStatus !== undefined &&
533
- isProxyPoolStaleStatus(proxyTunnelStatus)) {
386
+ if (proxyTunnelStatus !== undefined && isProxyPoolStaleStatus(proxyTunnelStatus)) {
534
387
  return createProxyPoolStaleError(proxyTunnelStatus, error instanceof Error ? error : undefined);
535
388
  }
536
389
  if (PROXY_CONNECT_FAILURE_BODY_PATTERN.test(message)) {
@@ -542,6 +395,9 @@ function normalizeStealthTransportError(error) {
542
395
  cause: error instanceof Error ? error : undefined,
543
396
  });
544
397
  }
398
+ function sleep(ms) {
399
+ return new Promise((resolve) => setTimeout(resolve, ms));
400
+ }
545
401
  function normalizeMethod(method) {
546
402
  switch (method.toUpperCase()) {
547
403
  case "HEAD":
@@ -608,18 +464,16 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
608
464
  return client;
609
465
  }
610
466
  async function resolveRequestProxy(options, proxyAttempt) {
611
- const rawProxyAttemptOffset = options?.proxyAttemptOffset ?? 0;
612
- const proxyAttemptOffset = Number.isFinite(rawProxyAttemptOffset)
613
- ? Math.max(0, Math.floor(rawProxyAttemptOffset))
614
- : 0;
615
467
  const resolvedProxy = await resolveProxyConfigAsync({
616
468
  proxy: options?.proxy ?? clientOptions.proxy,
617
469
  upstream: clientOptions.upstream,
618
470
  apifuseConfig: clientOptions.apifuseConfig,
619
471
  affinityKey: clientOptions.affinityKey,
620
- proxyAttempt: proxyAttempt === undefined
621
- ? proxyAttemptOffset
622
- : proxyAttemptOffset + proxyAttempt,
472
+ proxyAttempt: computeProxyAttemptIndex({
473
+ baseProxyAttempt: clientOptions.proxyAttempt,
474
+ proxyAttemptOffset: options?.proxyAttemptOffset,
475
+ retryAttemptOffset: proxyAttempt,
476
+ }),
623
477
  telemetry: clientOptions.telemetry,
624
478
  });
625
479
  if (resolvedProxy.shouldWarn && !hasWarnedMissingProxy) {
@@ -636,16 +490,28 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
636
490
  async fetch(url, options = {}) {
637
491
  const method = normalizeMethod(options.method ?? "GET");
638
492
  const hasExplicitRetryPolicy = options.retry !== undefined;
639
- const stealthRetryOptions = normalizeStealthRetryOptions(options.retry);
493
+ const stealthRetryOptions = normalizeProxyTransportRetryOptions(options.retry, {
494
+ extraErrorCodes: STEALTH_PROXY_TRANSPORT_RETRY_ERROR_CODES,
495
+ label: "Stealth",
496
+ }) ??
497
+ (hasExplicitRetryPolicy
498
+ ? undefined
499
+ : createDefaultProxyTransportRetryOptions({
500
+ extraErrorCodes: STEALTH_PROXY_TRANSPORT_RETRY_ERROR_CODES,
501
+ label: "Stealth",
502
+ }));
503
+ if (stealthRetryOptions) {
504
+ validateUnsafeProxyTransportRetryMethods(stealthRetryOptions, "Stealth");
505
+ }
640
506
  const hasPolicyProxy = isPolicyManagedProxy(clientOptions);
641
507
  const usesPolicyAllocator = hasPolicyProxy && !options.proxy && !clientOptions.proxy;
642
- const maxAttempts = usesPolicyAllocator
643
- ? Math.max(1, Math.min(MAX_POLICY_PROXY_RETRY_ATTEMPTS, clientOptions.proxyPolicy?.session?.poolSize ??
644
- (typeof clientOptions.upstream?.proxy === "object"
645
- ? clientOptions.upstream.proxy.session?.poolSize
646
- : undefined) ??
647
- DEFAULT_SMARTPROXY_POOL_SIZE))
648
- : 1;
508
+ const retryAttemptCap = Math.max(1, stealthRetryOptions?.attempts ?? 1);
509
+ const policyProxyAttemptCap = Math.max(1, Math.min(MAX_POLICY_PROXY_RETRY_ATTEMPTS, clientOptions.proxyPolicy?.session?.poolSize ??
510
+ (typeof clientOptions.upstream?.proxy === "object"
511
+ ? clientOptions.upstream.proxy.session?.poolSize
512
+ : undefined) ??
513
+ DEFAULT_SMARTPROXY_POOL_SIZE));
514
+ const maxAttempts = usesPolicyAllocator ? policyProxyAttemptCap : retryAttemptCap;
649
515
  let lastError;
650
516
  for (let refreshAttempt = 0; refreshAttempt <= MAX_POLICY_PROXY_POOL_REFRESHES; refreshAttempt += 1) {
651
517
  let stalePoolError;
@@ -666,9 +532,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
666
532
  ...(attemptProxy?.poolIndex === undefined
667
533
  ? {}
668
534
  : { poolIndex: attemptProxy.poolIndex }),
669
- ...(attemptProxy?.proxyHash
670
- ? { proxyHash: attemptProxy.proxyHash }
671
- : {}),
535
+ ...(attemptProxy?.proxyHash ? { proxyHash: attemptProxy.proxyHash } : {}),
672
536
  outcome,
673
537
  ...(errorCode ? { errorCode } : {}),
674
538
  ...(status === undefined ? {} : { status }),
@@ -679,16 +543,14 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
679
543
  assertNoUnsupportedFingerprintOverrides(options);
680
544
  attemptProxy = await resolveRequestProxy(options, attempt);
681
545
  proxy = attemptProxy.url;
682
- if (proxy) {
546
+ if (proxy && usesPolicyAllocator) {
683
547
  if (attemptedProxies.has(proxy)) {
684
548
  break;
685
549
  }
686
550
  attemptedProxies.add(proxy);
687
551
  }
688
552
  const ignoreTlsErrors = Boolean(options.stealth?.insecureSkipVerify ??
689
- (!hasPolicyProxy &&
690
- proxy &&
691
- clientOptions.proxyStealth?.insecureSkipVerify));
553
+ (!hasPolicyProxy && proxy && clientOptions.proxyStealth?.insecureSkipVerify));
692
554
  const profileName = options.profile ?? defaultProfile;
693
555
  const requestUrl = appendQueryParams(resolveUrl(baseUrl, url), options.params);
694
556
  const headers = { ...(options.headers ?? {}) };
@@ -709,17 +571,13 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
709
571
  const response = await getClient(profileName, proxy, ignoreTlsErrors).fetch(requestUrl, requestInit);
710
572
  const normalized = await normalizeResponse(response, requestUrl);
711
573
  cookieJar.setFromCookieStrings(setCookieHeadersFromResponse(response.headers));
712
- if (proxy &&
713
- isProxyConnectFailureResponse(response, normalized.body)) {
574
+ if (proxy && isProxyConnectFailureResponse(response, normalized.body)) {
714
575
  throw createProxyConnectFailureError(normalized.body);
715
576
  }
716
577
  if (response.status >= 400) {
717
578
  if (proxy &&
718
579
  usesPolicyAllocator &&
719
- isProxyEdgeTlsRejectedResponse(response.status, [
720
- JSON.stringify(responseHeadersToRecord(response.headers)),
721
- normalized.body,
722
- ].join("\n"))) {
580
+ isProxyEdgeTlsRejectedResponse(response.status, [JSON.stringify(responseHeadersToRecord(response.headers)), normalized.body].join("\n"))) {
723
581
  throw createProxyEdgeTlsRejectedError(response.status);
724
582
  }
725
583
  if (proxy && isProxyAuthIpDeniedMessage(normalized.body)) {
@@ -747,9 +605,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
747
605
  const normalizedError = normalizeStealthTransportError(error);
748
606
  recordProxyAttempt("error", proxyAttemptErrorCode(normalizedError), proxyAttemptStatus(normalizedError));
749
607
  lastError = normalizedError;
750
- if (proxy &&
751
- usesPolicyAllocator &&
752
- isProxyPoolRefreshableError(normalizedError)) {
608
+ if (proxy && usesPolicyAllocator && isProxyPoolRefreshableError(normalizedError)) {
753
609
  stalePoolError = normalizedError;
754
610
  if (shouldRunProxyAuthDiagnostic(normalizedError)) {
755
611
  stalePoolDiagnosticProxy = proxy;
@@ -759,15 +615,20 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
759
615
  }
760
616
  break;
761
617
  }
762
- if (proxy &&
763
- attempt + 1 <
764
- (stealthRetryOptions
765
- ? Math.min(maxAttempts, stealthRetryOptions.attempts)
766
- : maxAttempts) &&
767
- (!hasExplicitRetryPolicy
768
- ? isRetrySafeStealthMethod(method)
769
- : isExplicitStealthRetryAllowed(method, normalizedError, stealthRetryOptions)) &&
770
- isRetryableProxyTransportError(normalizedError)) {
618
+ if (attempt + 1 <
619
+ (stealthRetryOptions
620
+ ? Math.min(maxAttempts, stealthRetryOptions.attempts)
621
+ : maxAttempts) &&
622
+ shouldRetryProxyTransportAttempt({
623
+ error: normalizedError,
624
+ explicitRetry: hasExplicitRetryPolicy,
625
+ method,
626
+ options: stealthRetryOptions,
627
+ proxyUsed: Boolean(proxy),
628
+ })) {
629
+ if (stealthRetryOptions) {
630
+ await sleep(computeProxyTransportRetryDelayMs(stealthRetryOptions, attempt + 1));
631
+ }
771
632
  continue;
772
633
  }
773
634
  throw normalizedError;
@@ -920,10 +781,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
920
781
  }
921
782
  catch (error) {
922
783
  const message = error instanceof Error
923
- ? [
924
- error.message,
925
- error.cause instanceof Error ? error.cause.message : "",
926
- ]
784
+ ? [error.message, error.cause instanceof Error ? error.cause.message : ""]
927
785
  .filter(Boolean)
928
786
  .join(" ")
929
787
  : String(error);
@@ -951,12 +809,8 @@ function hasHeader(headers, name) {
951
809
  return Object.keys(headers).some((key) => key.toLowerCase() === needle);
952
810
  }
953
811
  export function createStealthClient(baseUrl, defaultProfileOrOptions = DEFAULT_PROFILE, clientOptions = {}) {
954
- const defaultProfile = typeof defaultProfileOrOptions === "string"
955
- ? defaultProfileOrOptions
956
- : DEFAULT_PROFILE;
957
- const resolvedClientOptions = typeof defaultProfileOrOptions === "string"
958
- ? clientOptions
959
- : defaultProfileOrOptions;
812
+ const defaultProfile = typeof defaultProfileOrOptions === "string" ? defaultProfileOrOptions : DEFAULT_PROFILE;
813
+ const resolvedClientOptions = typeof defaultProfileOrOptions === "string" ? clientOptions : defaultProfileOrOptions;
960
814
  let sharedSession = null;
961
815
  function getSharedSession() {
962
816
  if (!sharedSession) {
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { Hono } from "hono";
4
4
  import { z } from "zod";
5
5
  import { AuthAbortError, createAuthFlowHelpers } from "../auth";
6
- import { AuthError, ProviderError, SessionExpiredError, TransportError, } from "../errors";
6
+ import { AuthError, isProviderError, isSessionExpiredError, isTransportError, ProviderError, } from "../errors";
7
7
  import { loadProviderLocaleCatalogs, localizeAuthTurn, } from "../i18n/catalog";
8
8
  import { categoryForStatus, isRetryableCategory, PROVIDER_OBSERVABILITY_TAXONOMY_VERSION, } from "../observability";
9
9
  import { createScratchpad } from "../runtime/auth-flow";
@@ -107,7 +107,7 @@ function isProductionProviderBrowserMode(provider, env = process.env) {
107
107
  return (env.NODE_ENV === "production" && env.APIFUSE__PROVIDER__ID === provider.id);
108
108
  }
109
109
  export function resolveProviderProxyAffinityKey(provider, request, operationId) {
110
- const connectionKey = request.connection?.id ?? request.connection?.externalRef;
110
+ const connectionKey = resolveOperationConnectionId(request) ?? request.connection?.externalRef;
111
111
  const affinity = typeof provider.proxy === "object"
112
112
  ? provider.proxy.session?.affinity
113
113
  : undefined;
@@ -116,6 +116,9 @@ export function resolveProviderProxyAffinityKey(provider, request, operationId)
116
116
  }
117
117
  return connectionKey ?? provider.id;
118
118
  }
119
+ function resolveOperationConnectionId(request) {
120
+ return request.connection?.id ?? request.connectionId;
121
+ }
119
122
  function createProviderContext(provider, request, operationId, options = {}, state = createUnsupportedProviderRuntimeState(), proxyTelemetry) {
120
123
  const baseUrl = getProviderBaseUrl(provider);
121
124
  const stealthBaseUrl = getProviderStealthBaseUrl(provider);
@@ -142,7 +145,7 @@ function createProviderContext(provider, request, operationId, options = {}, sta
142
145
  values: request.connection?.secrets,
143
146
  });
144
147
  const requestContext = {
145
- connectionId: request.connection?.id,
148
+ connectionId: resolveOperationConnectionId(request),
146
149
  headers: request.headers ?? {},
147
150
  };
148
151
  const context = wrapWithInstrumentation({
@@ -290,7 +293,7 @@ function zodDetails(error) {
290
293
  }));
291
294
  }
292
295
  function toErrorResponse(error, requestId) {
293
- if (error instanceof ProviderError) {
296
+ if (isProviderError(error)) {
294
297
  const details = publicProviderErrorDetails(error);
295
298
  return {
296
299
  error: {
@@ -340,20 +343,25 @@ function publicProviderErrorDetails(error) {
340
343
  function isPlainRecord(value) {
341
344
  return value !== null && typeof value === "object" && !Array.isArray(value);
342
345
  }
346
+ // Accepts `unknown` so the branded guards narrow cleanly from the top: the
347
+ // subtype error classes are structurally compatible with ProviderError, so
348
+ // narrowing from a ProviderError-typed value would collapse the negative branch
349
+ // to `never`. Narrowing from unknown avoids that while still recognizing errors
350
+ // from a duplicate SDK module instance.
343
351
  function providerObservabilityDetails(error) {
344
352
  // Session-expiry surfaces the credential_expired category + the opt-in
345
353
  // retryable signal so Gateway/Credential Service can refresh and re-drive the
346
354
  // operation (see design.md §4.3 D3). Without this branch the auth error would
347
355
  // serialize as a bare 401 with no retryable/category, losing the refresh
348
356
  // signal for exactly the retryOnAuthRefresh operations it is meant to enable.
349
- if (error instanceof SessionExpiredError) {
357
+ if (isSessionExpiredError(error)) {
350
358
  return {
351
359
  category: error.options?.category ?? "credential_expired",
352
360
  taxonomyVersion: PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
353
361
  retryable: error.options?.retryable ?? false,
354
362
  };
355
363
  }
356
- if (!(error instanceof TransportError)) {
364
+ if (!isTransportError(error)) {
357
365
  return undefined;
358
366
  }
359
367
  const isProxyPoolCode = error.code === PROXY_POOL_EXHAUSTED_CODE ||
@@ -382,7 +390,7 @@ function providerObservabilityDetails(error) {
382
390
  };
383
391
  }
384
392
  function publicProviderErrorMessage(error) {
385
- if (error instanceof TransportError) {
393
+ if (isTransportError(error)) {
386
394
  if (error.code === PROXY_AUTH_IP_DENIED_CODE) {
387
395
  return error.message;
388
396
  }
@@ -410,10 +418,10 @@ function toStatusCode(error) {
410
418
  if (error instanceof z.ZodError) {
411
419
  return 400;
412
420
  }
413
- if (error instanceof TransportError) {
421
+ if (isTransportError(error)) {
414
422
  return error.code === "transport_timeout" ? 504 : 502;
415
423
  }
416
- if (error instanceof ProviderError) {
424
+ if (isProviderError(error)) {
417
425
  switch (error.code) {
418
426
  case "AUTH_REQUIRED":
419
427
  case "reauth_required":
@@ -445,14 +453,14 @@ function extractRequestId(raw) {
445
453
  return typeof value === "string" ? value : undefined;
446
454
  }
447
455
  function logProviderError(logger, provider, kind, route, requestId, error, status, cost) {
448
- const code = error instanceof ProviderError
456
+ const code = isProviderError(error)
449
457
  ? (error.code ?? "provider_error")
450
458
  : error instanceof z.ZodError
451
459
  ? "invalid_request"
452
460
  : "internal_error";
453
461
  const errorClass = error instanceof Error ? error.name : typeof error;
454
462
  const message = error instanceof Error ? error.message : String(error);
455
- const details = error instanceof ProviderError
463
+ const details = isProviderError(error)
456
464
  ? providerObservabilityDetails(error)
457
465
  : undefined;
458
466
  const emit = typeof logger === "function" ? logger : defaultProviderServerLogger;
@@ -468,7 +476,7 @@ function logProviderError(logger, provider, kind, route, requestId, error, statu
468
476
  code,
469
477
  errorClass,
470
478
  message,
471
- ...(error instanceof TransportError && error.upstreamStatus
479
+ ...(isTransportError(error) && error.upstreamStatus
472
480
  ? { upstreamStatus: error.upstreamStatus }
473
481
  : {}),
474
482
  ...(details
@@ -21,6 +21,7 @@ export declare const OperationConnectionSchema: z.ZodObject<{
21
21
  export declare const OperationRequestSchema: z.ZodObject<{
22
22
  requestId: z.ZodString;
23
23
  input: z.ZodRecord<z.ZodString, z.ZodUnknown>;
24
+ connectionId: z.ZodOptional<z.ZodString>;
24
25
  connection: z.ZodOptional<z.ZodObject<{
25
26
  id: z.ZodString;
26
27
  mode: z.ZodEnum<{
@@ -17,6 +17,7 @@ export const OperationConnectionSchema = z.object({
17
17
  export const OperationRequestSchema = z.object({
18
18
  requestId: z.string(),
19
19
  input: z.record(z.string(), z.unknown()),
20
+ connectionId: z.string().optional(),
20
21
  connection: OperationConnectionSchema.optional(),
21
22
  headers: z.record(z.string(), z.string()).optional(),
22
23
  trace: z.record(z.string(), z.string()).optional(),
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.2.0-beta.1",
2
+ "version": "2.2.0-beta.3",
3
3
  "name": "@apifuse/provider-sdk",
4
4
  "private": false,
5
5
  "type": "module",
@@ -93,6 +93,7 @@
93
93
  },
94
94
  "dependencies": {
95
95
  "@clack/prompts": "^1.5.1",
96
+ "@rgrove/parse-xml": "4.2.2",
96
97
  "@types/ms": "^2.1.0",
97
98
  "acorn": "^8.17.0",
98
99
  "ajv": "^8.17",
package/src/errors.ts CHANGED
@@ -1,5 +1,46 @@
1
1
  import type { ProviderErrorCategory } from "./observability";
2
2
 
3
+ // Versioned, cross-realm brands. `Symbol.for` resolves to the same symbol in
4
+ // any copy/entrypoint of this SDK major version, so an error created by a
5
+ // duplicate module instance (e.g. the packaged CLI's src/* server vs a
6
+ // provider's dist/* import) still carries a brand the server can recognize even
7
+ // though `instanceof` splits across the two constructors. The `@1` suffix lets a
8
+ // future breaking change to this contract mint a distinct key.
9
+ const PROVIDER_ERROR_BRAND = Symbol.for("@apifuse/provider-sdk/error-brand@1");
10
+ const PROVIDER_ERROR_BRAND_VALUE = 1;
11
+ const SESSION_EXPIRED_BRAND = Symbol.for(
12
+ "@apifuse/provider-sdk/error-kind/session-expired@1",
13
+ );
14
+ const TRANSPORT_BRAND = Symbol.for("@apifuse/provider-sdk/error-kind/transport@1");
15
+
16
+ // Defines a non-enumerable, non-writable, non-configurable own data property.
17
+ // Immutable + own means a guard can trust it via a single descriptor read
18
+ // without invoking attacker-controlled getters or accepting inherited brands.
19
+ function defineErrorBrand(target: object, brand: symbol, value: number | true): void {
20
+ Object.defineProperty(target, brand, {
21
+ value,
22
+ enumerable: false,
23
+ writable: false,
24
+ configurable: false,
25
+ });
26
+ }
27
+
28
+ // Recognizes an own data-property brand with the expected value. Rejects
29
+ // missing brands (unbranded lookalikes), accessor brands (no own `value`
30
+ // slot — the getter is never called), and inherited brands (own-descriptor
31
+ // lookup returns undefined on the child).
32
+ function hasOwnBrand(value: unknown, brand: symbol, expected: number | true): boolean {
33
+ if (value === null || (typeof value !== "object" && typeof value !== "function")) {
34
+ return false;
35
+ }
36
+ const descriptor = Object.getOwnPropertyDescriptor(value, brand);
37
+ return (
38
+ descriptor !== undefined &&
39
+ Object.hasOwn(descriptor, "value") &&
40
+ descriptor.value === expected
41
+ );
42
+ }
43
+
3
44
  export type ProviderErrorOptions = {
4
45
  fix?: string;
5
46
  code?: string;
@@ -19,6 +60,7 @@ export class ProviderError extends Error {
19
60
  if (options?.cause) {
20
61
  this.cause = options.cause;
21
62
  }
63
+ defineErrorBrand(this, PROVIDER_ERROR_BRAND, PROVIDER_ERROR_BRAND_VALUE);
22
64
  }
23
65
 
24
66
  get fix(): string | undefined {
@@ -60,6 +102,7 @@ export class SessionExpiredError extends AuthError {
60
102
  ...options,
61
103
  });
62
104
  this.name = "SessionExpiredError";
105
+ defineErrorBrand(this, SESSION_EXPIRED_BRAND, true);
63
106
  }
64
107
  }
65
108
 
@@ -91,9 +134,26 @@ export class TransportError extends ProviderError {
91
134
  this.name = "TransportError";
92
135
  this.status = options?.status;
93
136
  this.upstreamStatus = options?.upstreamStatus ?? options?.status;
137
+ defineErrorBrand(this, TRANSPORT_BRAND, true);
94
138
  }
95
139
  }
96
140
 
141
+ // Cross-module type guards. Prefer these over `instanceof` at any boundary that
142
+ // may receive an error from a different copy/entrypoint of the SDK (see the HTTP
143
+ // server error boundary). They recognize branded errors regardless of which
144
+ // module instance constructed them, while rejecting unbranded lookalikes.
145
+ export function isProviderError(value: unknown): value is ProviderError {
146
+ return hasOwnBrand(value, PROVIDER_ERROR_BRAND, PROVIDER_ERROR_BRAND_VALUE);
147
+ }
148
+
149
+ export function isSessionExpiredError(value: unknown): value is SessionExpiredError {
150
+ return isProviderError(value) && hasOwnBrand(value, SESSION_EXPIRED_BRAND, true);
151
+ }
152
+
153
+ export function isTransportError(value: unknown): value is TransportError {
154
+ return isProviderError(value) && hasOwnBrand(value, TRANSPORT_BRAND, true);
155
+ }
156
+
97
157
  export class ProviderSecretError extends ProviderError {
98
158
  constructor(message: string, options?: ProviderErrorOptions) {
99
159
  super(message, { code: "provider_secret_error", ...options });
package/src/provider.ts CHANGED
@@ -37,6 +37,9 @@ export {
37
37
  } from "./define";
38
38
  export {
39
39
  AuthError,
40
+ isProviderError,
41
+ isSessionExpiredError,
42
+ isTransportError,
40
43
  ProviderError,
41
44
  SessionExpiredError,
42
45
  TransportError,
@@ -1,4 +1,4 @@
1
- import { ProviderError, SessionExpiredError } from "../errors";
1
+ import { isSessionExpiredError, ProviderError, SessionExpiredError } from "../errors";
2
2
  import { parseSchema } from "../schema";
3
3
  import type { ProviderContext, ProviderDefinition } from "../types";
4
4
 
@@ -64,7 +64,12 @@ export async function executeOperation(
64
64
  // operation is safe to re-drive after refresh, which we signal by marking
65
65
  // the surfaced error retryable; non-idempotent operations (the default)
66
66
  // stay non-retryable so they are not auto-re-driven. See design.md §4.3 D3.
67
- if (error instanceof SessionExpiredError && operation.retryOnAuthRefresh) {
67
+ // Use the branded guard, not `instanceof`: a handler loaded through a
68
+ // duplicate/published SDK module can throw a correctly branded
69
+ // SessionExpiredError whose constructor identity differs from this
70
+ // executor's, which `instanceof` would miss — dropping the retryable
71
+ // upgrade and stranding an operation that opted into auth refresh.
72
+ if (isSessionExpiredError(error) && operation.retryOnAuthRefresh) {
68
73
  throw new SessionExpiredError(error.message, { retryable: true });
69
74
  }
70
75
  throw error;