@apifuse/provider-sdk 2.2.0-beta.57 → 2.2.0-beta.59

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.
Files changed (107) hide show
  1. package/AUTHORING.md +9 -0
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +22 -5
  4. package/SUBMISSION.md +2 -1
  5. package/bin/apifuse-pack-types.ts +49 -0
  6. package/bin/apifuse-record.ts +6 -0
  7. package/dist/cli/templates/provider/README.md.tpl +19 -5
  8. package/dist/config/loader.d.ts +7 -0
  9. package/dist/config/loader.js +30 -6
  10. package/dist/declaration-validation.d.ts +2 -1
  11. package/dist/declaration-validation.js +39 -0
  12. package/dist/define.d.ts +5 -4
  13. package/dist/define.js +25 -1
  14. package/dist/engine.d.ts +16 -0
  15. package/dist/engine.js +58 -1
  16. package/dist/error-resolution.js +13 -0
  17. package/dist/index.d.ts +6 -4
  18. package/dist/index.js +4 -2
  19. package/dist/lint.js +3 -3
  20. package/dist/provider.d.ts +3 -3
  21. package/dist/provider.js +2 -2
  22. package/dist/runtime/bounded-expiring-map.d.ts +21 -0
  23. package/dist/runtime/bounded-expiring-map.js +64 -0
  24. package/dist/runtime/cache.d.ts +2 -0
  25. package/dist/runtime/cache.js +8 -1
  26. package/dist/runtime/egress-lease.d.ts +63 -0
  27. package/dist/runtime/egress-lease.js +233 -0
  28. package/dist/runtime/http.js +1 -0
  29. package/dist/runtime/proxy-retry-policy.d.ts +4 -1
  30. package/dist/runtime/proxy-retry-policy.js +11 -2
  31. package/dist/runtime/request-telemetry.d.ts +7 -9
  32. package/dist/runtime/resolver-config.d.ts +7 -0
  33. package/dist/runtime/resolver-config.js +8 -0
  34. package/dist/runtime/resolver-public.d.ts +3 -1
  35. package/dist/runtime/resolver-public.js +1 -1
  36. package/dist/runtime/resolver-shared.d.ts +9 -0
  37. package/dist/runtime/resolver-shared.js +57 -0
  38. package/dist/runtime/resolver-telemetry.d.ts +134 -0
  39. package/dist/runtime/resolver-telemetry.js +204 -0
  40. package/dist/runtime/resolver-usage.d.ts +24 -0
  41. package/dist/runtime/resolver-usage.js +60 -0
  42. package/dist/runtime/resolver-vendors/bindings.d.ts +5 -0
  43. package/dist/runtime/resolver-vendors/bindings.js +5 -0
  44. package/dist/runtime/resolver-vendors/capsolver.d.ts +2 -2
  45. package/dist/runtime/resolver-vendors/capsolver.js +21 -9
  46. package/dist/runtime/resolver-vendors/hypersolutions.d.ts +27 -0
  47. package/dist/runtime/resolver-vendors/hypersolutions.js +369 -0
  48. package/dist/runtime/resolver-vendors/twocaptcha.d.ts +2 -2
  49. package/dist/runtime/resolver-vendors/twocaptcha.js +26 -10
  50. package/dist/runtime/resolver-vendors/types.d.ts +35 -5
  51. package/dist/runtime/resolver-vendors/types.js +20 -10
  52. package/dist/runtime/resolver.d.ts +5 -1
  53. package/dist/runtime/resolver.js +397 -109
  54. package/dist/runtime/stealth-akamai-sbsd.d.ts +54 -0
  55. package/dist/runtime/stealth-akamai-sbsd.js +142 -0
  56. package/dist/runtime/stealth.js +547 -53
  57. package/dist/schema.d.ts +9 -0
  58. package/dist/schema.js +16 -0
  59. package/dist/server/index.d.ts +2 -0
  60. package/dist/server/index.js +1 -0
  61. package/dist/server/self-test.d.ts +4 -0
  62. package/dist/server/self-test.js +10 -1
  63. package/dist/server/serve-implementation.d.ts +7 -1
  64. package/dist/server/serve-implementation.js +181 -53
  65. package/dist/server/types.d.ts +6 -0
  66. package/dist/server/types.js +14 -0
  67. package/dist/stealth/profiles.d.ts +7 -1
  68. package/dist/stealth/profiles.js +9 -0
  69. package/dist/testing/run.js +6 -0
  70. package/dist/types.d.ts +115 -12
  71. package/package.json +1 -1
  72. package/src/cli/templates/provider/README.md.tpl +19 -5
  73. package/src/config/loader.ts +34 -6
  74. package/src/declaration-validation.ts +47 -1
  75. package/src/define.ts +46 -3
  76. package/src/engine.ts +76 -1
  77. package/src/error-resolution.ts +13 -0
  78. package/src/index.ts +32 -1
  79. package/src/lint.ts +3 -3
  80. package/src/provider.ts +9 -0
  81. package/src/runtime/bounded-expiring-map.ts +67 -0
  82. package/src/runtime/cache.ts +10 -1
  83. package/src/runtime/egress-lease.ts +336 -0
  84. package/src/runtime/http.ts +1 -0
  85. package/src/runtime/proxy-retry-policy.ts +12 -1
  86. package/src/runtime/request-telemetry.ts +5 -7
  87. package/src/runtime/resolver-config.ts +12 -0
  88. package/src/runtime/resolver-public.ts +21 -0
  89. package/src/runtime/resolver-shared.ts +75 -0
  90. package/src/runtime/resolver-telemetry.ts +362 -0
  91. package/src/runtime/resolver-usage.ts +96 -0
  92. package/src/runtime/resolver-vendors/bindings.ts +5 -0
  93. package/src/runtime/resolver-vendors/capsolver.ts +22 -3
  94. package/src/runtime/resolver-vendors/hypersolutions.ts +480 -0
  95. package/src/runtime/resolver-vendors/twocaptcha.ts +26 -12
  96. package/src/runtime/resolver-vendors/types.ts +60 -12
  97. package/src/runtime/resolver.ts +465 -124
  98. package/src/runtime/stealth-akamai-sbsd.ts +207 -0
  99. package/src/runtime/stealth.ts +727 -95
  100. package/src/schema.ts +17 -0
  101. package/src/server/index.ts +21 -0
  102. package/src/server/self-test.ts +13 -0
  103. package/src/server/serve-implementation.ts +268 -48
  104. package/src/server/types.ts +14 -0
  105. package/src/stealth/profiles.ts +10 -0
  106. package/src/testing/run.ts +6 -0
  107. package/src/types.ts +130 -22
@@ -1,12 +1,15 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { Cookie } from "tough-cookie";
2
3
  import { DEFAULT_SMARTPROXY_POOL_SIZE, invalidateProxyResolutionCacheAsync, ProxyResolutionError, policyResolvesRegistryVendorChain, resolvePolicyProxyPoolSpan, resolvePolicyTransportAttemptCap, resolveProxyConfigAsync, vendorFromResolvedSource, } from "../config/loader.js";
3
4
  import { SDKError, TransportError } from "../errors.js";
4
- import { DEFAULT_STEALTH_BROWSER, DEFAULT_STEALTH_OS, getStealthProfile, resolveStealthProfileSelection, } from "../stealth/profiles.js";
5
+ import { DEFAULT_STEALTH_BROWSER, DEFAULT_STEALTH_OS, getStealthProfile, resolverClientProfileFamily, resolveStealthProfileSelection, } from "../stealth/profiles.js";
5
6
  import { chrome149HeaderOrder } from "./chrome149-header-order.js";
7
+ import { ENGINE_CEREMONY_EGRESS_LEASE, } from "./egress-lease.js";
6
8
  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";
7
- import { computeProxyAttemptIndex, computeProxyTransportRetryDelayMs, createDefaultProxyTransportRetryOptions, normalizeProxyTransportRetryOptions, shouldRetryProxyTransportAttempt, validateUnsafeProxyTransportRetryMethods, } from "./proxy-retry-policy.js";
9
+ import { computeProxyAttemptIndex, computeProxyTransportRetryDelayMs, createDefaultProxyTransportRetryOptions, isProxyTransportRetryMethod, normalizeProxyTransportRetryOptions, shouldRetryProxyTransportAttempt, validateUnsafeProxyTransportRetryMethods, } from "./proxy-retry-policy.js";
8
10
  import { evaluateRedirectHop, isRedirectStatus, nextRedirectMethod, resolveRedirectUrl, } from "./redirects.js";
9
11
  import { isSensitiveKey, normalizeSensitiveParams, redactSensitiveError, redactSensitiveRequestError, redactSensitiveText, redactUrlQueryParams, serializeRequestUrl, } from "./request-options.js";
12
+ import { akamaiSbsdChallengeKey, detectAkamaiSbsdChallenge, } from "./stealth-akamai-sbsd.js";
10
13
  import { StealthCookieJar } from "./stealth-cookies.js";
11
14
  export const DEFAULT_STEALTH_PROFILE = Object.freeze({
12
15
  browser: DEFAULT_STEALTH_BROWSER,
@@ -19,6 +22,18 @@ const PROXY_CONNECT_FAILURE_BODY_PATTERN = /\bproxy\b.*\b(non[\s-]?200|connect|t
19
22
  const PROXY_AUTH_DIAGNOSTIC_URL = "http://example.com/";
20
23
  const PROXY_AUTH_DIAGNOSTIC_TIMEOUT_MS = 5_000;
21
24
  const STEALTH_PROXY_TRANSPORT_RETRY_ERROR_CODES = [PROXY_CONNECT_FAILURE_CODE];
25
+ // Failures that mean the bound ceremony endpoint did not carry the request to the origin.
26
+ // Proxy-level refusals are covered by isProxyPoolRefreshableError; PROXY_AUTH_IP_DENIED is
27
+ // deliberately absent because another endpoint of the same vendor is denied just the same.
28
+ const BOUND_EGRESS_TRANSPORT_FAILURE_CODES = new Set([
29
+ PROXY_CONNECT_FAILURE_CODE,
30
+ "transport_network_error",
31
+ "transport_timeout",
32
+ ]);
33
+ function isBoundEgressTransportFailure(error) {
34
+ return (isProxyPoolRefreshableError(error) ||
35
+ (error.code !== undefined && BOUND_EGRESS_TRANSPORT_FAILURE_CODES.has(error.code)));
36
+ }
22
37
  const MAX_STEALTH_REDIRECT_HOPS = 10;
23
38
  const REDIRECT_BODY_HEADERS = new Set([
24
39
  "content-encoding",
@@ -37,6 +52,14 @@ function sensitiveQueryParamNames(url) {
37
52
  const query = url.slice(queryStart + 1, fragmentStart === -1 ? undefined : fragmentStart);
38
53
  return [...new URLSearchParams(query).keys()].filter(isSensitiveKey);
39
54
  }
55
+ function assertAkamaiSbsdClientProfile(declared, session) {
56
+ if (declared === undefined || resolverClientProfileFamily(declared) === session.browser)
57
+ return;
58
+ throw new SDKError(`Resolver client profile "${declared}" does not match the initiating stealth session browser "${session.browser}"`, {
59
+ code: "RESOLVER_CLIENT_PROFILE_MISMATCH",
60
+ fix: "Make resolver.clientProfile name the provider stealth browser family (chrome, firefox, or safari).",
61
+ });
62
+ }
40
63
  let wreqModulePromise;
41
64
  function getWreqModule() {
42
65
  if (!wreqModulePromise) {
@@ -98,6 +121,25 @@ export function resolveWreqProfile(selection, wreqProfiles) {
98
121
  function resolveUrl(baseUrl, url) {
99
122
  return new URL(url, baseUrl).toString();
100
123
  }
124
+ function resolveRequestUrl(baseUrl, url) {
125
+ try {
126
+ return resolveUrl(baseUrl, url);
127
+ }
128
+ catch (error) {
129
+ // Both inputs are caller-owned, so an unparsable URL is a programming error, never a
130
+ // transport condition: not retryable, not the upstream's fault. Classify it here rather
131
+ // than in normalizeStealthTransportError, which also sees ERR_INVALID_URL from malformed
132
+ // upstream redirect targets. The fixed message keeps the URL (and any inline query
133
+ // values) out of the diagnostic surface; the runtime TypeError stays in `cause`.
134
+ throw new TransportError("Invalid request URL", {
135
+ code: "transport_invalid_url",
136
+ status: 0,
137
+ category: "provider_error",
138
+ retryable: false,
139
+ ...(error instanceof Error ? { cause: error } : {}),
140
+ });
141
+ }
142
+ }
101
143
  function headerEntriesFromHeaders(headers) {
102
144
  return Array.from(headers.entries());
103
145
  }
@@ -365,7 +407,8 @@ function buildChromeHeaderTuples(options) {
365
407
  ]);
366
408
  if (isNavigation) {
367
409
  values.set("upgrade-insecure-requests", "1");
368
- values.set("sec-fetch-user", "?1");
410
+ if (options.userActivation !== false)
411
+ values.set("sec-fetch-user", "?1");
369
412
  }
370
413
  for (const [name, value] of callerEntries)
371
414
  values.set(name, value);
@@ -430,6 +473,29 @@ function setCookieHeadersFromResponse(headers) {
430
473
  const setCookie = headers.get("set-cookie");
431
474
  return setCookie ? splitCombinedSetCookieHeader(setCookie) : [];
432
475
  }
476
+ function resolverCookiesFromResponse(response, requestUrl) {
477
+ const responseUrl = response.url ?? requestUrl;
478
+ return setCookieHeadersFromResponse(response.headers).flatMap((cookieString) => {
479
+ const cookie = Cookie.parse(cookieString);
480
+ if (!cookie?.key)
481
+ return [];
482
+ const expires = cookie.expires instanceof Date && Number.isFinite(cookie.expires.getTime())
483
+ ? cookie.expires.getTime() / 1_000
484
+ : undefined;
485
+ return [
486
+ {
487
+ name: cookie.key,
488
+ value: cookie.value,
489
+ ...(expires === undefined ? {} : { expires }),
490
+ httpOnly: cookie.httpOnly,
491
+ secure: cookie.secure,
492
+ ...(cookie.domain ? { domain: cookie.domain } : { domain: new URL(responseUrl).hostname }),
493
+ ...(cookie.path ? { path: cookie.path } : {}),
494
+ ...(cookie.sameSite ? { sameSite: cookie.sameSite } : {}),
495
+ },
496
+ ];
497
+ });
498
+ }
433
499
  function splitCombinedSetCookieHeader(headerValue) {
434
500
  const cookieNamePattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+=/;
435
501
  const cookieStrings = [];
@@ -605,15 +671,20 @@ function readResponseBodyChunk(reader, signal) {
605
671
  }
606
672
  function normalizeBody(body) {
607
673
  if (body === undefined) {
608
- return "";
674
+ return undefined;
609
675
  }
610
676
  if (typeof body === "string") {
611
677
  return body;
612
678
  }
613
679
  if (Buffer.isBuffer(body)) {
614
- return body.toString();
680
+ return Buffer.from(body);
615
681
  }
616
- return String(body);
682
+ // `StealthFetchOptions.body` is `string | Buffer`; anything else is a provider fault,
683
+ // caught before the first request so a later replay can send the same bytes.
684
+ throw new SDKError("Stealth request bodies must be a string or Buffer", {
685
+ code: "STEALTH_BODY_UNSUPPORTED",
686
+ fix: "Supply a string or Buffer body; streams and typed arrays are not snapshotted.",
687
+ });
617
688
  }
618
689
  function isPolicyManagedProxy(options) {
619
690
  const policy = options.proxyPolicy ?? options.upstream?.proxy;
@@ -655,6 +726,10 @@ function proxyPoolIndexFromDiagnostics(diagnostics) {
655
726
  }
656
727
  return Math.floor(value);
657
728
  }
729
+ function positiveDiagnosticNumber(diagnostics, name) {
730
+ const value = diagnostics?.[name];
731
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
732
+ }
658
733
  function proxyEndpointHash(proxyUrl) {
659
734
  if (!proxyUrl)
660
735
  return undefined;
@@ -902,6 +977,65 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
902
977
  let hasWarnedMissingProxy = false;
903
978
  const warn = clientOptions.warn ?? console.warn;
904
979
  const cookieJar = new StealthCookieJar([], baseUrl);
980
+ const akamaiSbsdState = { transactions: new Map() };
981
+ const challengedReplays = new WeakMap();
982
+ const ceremonyEgressLease = clientOptions[ENGINE_CEREMONY_EGRESS_LEASE];
983
+ // The lease is shared by every session of this client; this is the lease generation this
984
+ // session's challenge state and replay records were built against.
985
+ let observedEgressGeneration = ceremonyEgressLease?.generation ?? 0;
986
+ const automaticChallengeRefetchPolicy = {
987
+ ...createDefaultProxyTransportRetryOptions({ label: "Stealth" }),
988
+ methods: ["GET"],
989
+ };
990
+ function clearAkamaiSbsdState() {
991
+ akamaiSbsdState.rememberedScript = undefined;
992
+ akamaiSbsdState.transactions.clear();
993
+ akamaiSbsdState.completedSuccessKey = undefined;
994
+ }
995
+ function currentEgressGeneration() {
996
+ return ceremonyEgressLease?.generation ?? 0;
997
+ }
998
+ /**
999
+ * Whoever dropped the binding (this session by expiry or release, or another session of
1000
+ * the same client), the challenge state that was only valid on the retired endpoint goes
1001
+ * with it, and replay records made against the old generation stop matching.
1002
+ */
1003
+ function syncCeremonyEgress() {
1004
+ const generation = currentEgressGeneration();
1005
+ if (generation === observedEgressGeneration)
1006
+ return;
1007
+ observedEgressGeneration = generation;
1008
+ clearAkamaiSbsdState();
1009
+ }
1010
+ /**
1011
+ * A binding past its vendor session lifetime is dropped, together with the challenge
1012
+ * state that was only valid on that endpoint, so this request selects and binds afresh.
1013
+ */
1014
+ function expireCeremonyEgressLease() {
1015
+ ceremonyEgressLease?.dropExpiredBinding();
1016
+ syncCeremonyEgress();
1017
+ }
1018
+ /**
1019
+ * The lease is best effort (ADR-0010: a raw Smartproxy endpoint is not a vendor-guaranteed
1020
+ * lease). When the attempt that ran on the bound endpoint fails at the transport (the
1021
+ * proxy refused it, the tunnel never opened, the socket died or timed out) the binding is
1022
+ * released so the ceremony can leave a dead endpoint before its lifetime elapses. Origin
1023
+ * responses, however bad, keep the binding: the endpoint is alive. Returns true when a
1024
+ * binding was released.
1025
+ */
1026
+ function releaseFailedCeremonyEgress(attemptProxyUrl, error) {
1027
+ const bound = ceremonyEgressLease?.binding;
1028
+ if (!ceremonyEgressLease ||
1029
+ !bound ||
1030
+ !attemptProxyUrl ||
1031
+ attemptProxyUrl !== bound.proxyUrl ||
1032
+ !isBoundEgressTransportFailure(error)) {
1033
+ return false;
1034
+ }
1035
+ ceremonyEgressLease.dropBinding();
1036
+ syncCeremonyEgress();
1037
+ return true;
1038
+ }
905
1039
  async function getClientEntry(profile, proxyUrl, ignoreTlsErrors, defaultHeaders) {
906
1040
  if (closed) {
907
1041
  throw new TransportError("Stealth session is closed", { status: 0 });
@@ -984,6 +1118,23 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
984
1118
  });
985
1119
  }
986
1120
  async function resolveRequestProxy(options, proxyAttempt, refreshEpoch) {
1121
+ const bound = ceremonyEgressLease?.binding;
1122
+ if (bound) {
1123
+ if (options?.proxy !== undefined || options?.proxyAttemptOffset !== undefined) {
1124
+ throw new SDKError("A ceremony-bound egress cannot be overridden by provider request options", {
1125
+ code: "EGRESS_LEASE_BINDING_INVALID",
1126
+ fix: "Remove `proxy` and `proxyAttemptOffset` from stealth requests made inside an auth ceremony.",
1127
+ });
1128
+ }
1129
+ return {
1130
+ url: bound.proxyUrl,
1131
+ poolIndex: bound.poolIndex,
1132
+ proxyHash: proxyEndpointHash(bound.proxyUrl),
1133
+ vendor: bound.vendor,
1134
+ refreshEpoch: bound.refreshEpoch,
1135
+ lifetimeMinutes: bound.lifetimeMinutes,
1136
+ };
1137
+ }
987
1138
  const resolvedProxy = await resolveProxyConfigAsync({
988
1139
  proxy: options?.proxy ?? clientOptions.proxy,
989
1140
  upstream: clientOptions.upstream,
@@ -998,6 +1149,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
998
1149
  transportProtocols: ["http", "socks5"],
999
1150
  ...(refreshEpoch === undefined ? {} : { proxyRefreshEpoch: refreshEpoch }),
1000
1151
  telemetry: clientOptions.telemetry,
1152
+ engineCredentials: clientOptions.engineCredentials,
1001
1153
  });
1002
1154
  if (resolvedProxy.shouldWarn && !hasWarnedMissingProxy) {
1003
1155
  hasWarnedMissingProxy = true;
@@ -1008,11 +1160,72 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
1008
1160
  poolIndex: proxyPoolIndexFromDiagnostics(resolvedProxy.diagnostics),
1009
1161
  proxyHash: proxyEndpointHash(resolvedProxy.url),
1010
1162
  vendor: vendorFromResolvedSource(resolvedProxy.source),
1163
+ refreshEpoch: refreshEpoch ?? 0,
1164
+ ...(positiveDiagnosticNumber(resolvedProxy.diagnostics, "lifetimeMinutes") === undefined
1165
+ ? {}
1166
+ : {
1167
+ lifetimeMinutes: positiveDiagnosticNumber(resolvedProxy.diagnostics, "lifetimeMinutes"),
1168
+ }),
1169
+ };
1170
+ }
1171
+ function bindCeremonyEgress(attemptProxy) {
1172
+ const lease = ceremonyEgressLease;
1173
+ if (!lease || !attemptProxy?.url || !attemptProxy.vendor)
1174
+ return;
1175
+ if (lease.binding) {
1176
+ // A concurrent request on this session may have bound the ceremony while this one
1177
+ // was in flight on an endpoint it selected before the binding existed (pool
1178
+ // rotation after a transport failure). Its response reached the origin from an
1179
+ // egress the handle does not name; accepting it would split the ceremony identity.
1180
+ if (attemptProxy.url !== lease.binding.proxyUrl) {
1181
+ throw new SDKError("The response arrived on an egress other than the ceremony's bound endpoint", {
1182
+ code: "EGRESS_LEASE_BINDING_INVALID",
1183
+ });
1184
+ }
1185
+ return;
1186
+ }
1187
+ if (attemptProxy.poolIndex === undefined ||
1188
+ attemptProxy.lifetimeMinutes === undefined ||
1189
+ !clientOptions.affinityKey) {
1190
+ // Registry vendors always report pool index and session lifetime; missing either
1191
+ // is an SDK fault, not something the caller can correct.
1192
+ throw new SDKError("The selected egress cannot be represented by a ceremony lease", {
1193
+ code: "EGRESS_LEASE_BINDING_INVALID",
1194
+ });
1195
+ }
1196
+ const binding = {
1197
+ vendor: attemptProxy.vendor,
1198
+ proxyUrl: attemptProxy.url,
1199
+ poolIndex: attemptProxy.poolIndex,
1200
+ affinityKey: clientOptions.affinityKey,
1201
+ refreshEpoch: attemptProxy.refreshEpoch ?? 0,
1202
+ lifetimeMinutes: attemptProxy.lifetimeMinutes,
1011
1203
  };
1204
+ lease.bind(binding);
1012
1205
  }
1013
1206
  const session = {
1014
- async fetch(url, options = {}) {
1207
+ async fetch(url, callerOptions = {}) {
1208
+ expireCeremonyEgressLease();
1209
+ const requestBody = normalizeBody(callerOptions.body);
1210
+ // Snapshot the caller's headers with the body before the first send: the first
1211
+ // request and an explicit replay both use this copy, so a caller mutating its own
1212
+ // objects afterwards cannot make the two sends differ.
1213
+ const options = {
1214
+ ...callerOptions,
1215
+ ...(callerOptions.headers
1216
+ ? {
1217
+ headers: Object.fromEntries(Object.entries(callerOptions.headers).map(([name, value]) => [
1218
+ name,
1219
+ Array.isArray(value) ? [...value] : value,
1220
+ ])),
1221
+ }
1222
+ : {}),
1223
+ ...(requestBody === undefined ? {} : { body: requestBody }),
1224
+ };
1015
1225
  const requestProfile = resolveStealthProfileSelection(options.stealth, defaultProfile);
1226
+ let challengeSolveAttempted = false;
1227
+ let challengeRefetchAttempted = false;
1228
+ let challengeSolveFailure;
1016
1229
  const { hasExplicitRetryPolicy, method, stealthRetryOptions } = (() => {
1017
1230
  try {
1018
1231
  const method = normalizeMethod(options.method ?? "GET");
@@ -1059,7 +1272,10 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
1059
1272
  // have no managed endpoint to rotate or refresh, so they follow the ordinary
1060
1273
  // transport-retry budget instead.
1061
1274
  const rotatesRegistryChain = usesPolicyAllocator && policyResolvesRegistryVendorChain(policyProxy);
1062
- const maxAttempts = rotatesRegistryChain ? policyProxyAttemptCap : retryAttemptCap;
1275
+ const unboundMaxAttempts = rotatesRegistryChain ? policyProxyAttemptCap : retryAttemptCap;
1276
+ // A bound ceremony has exactly one endpoint to try; releasing the binding after a
1277
+ // transport failure widens the budget back to the ordinary rotation span.
1278
+ let maxAttempts = ceremonyEgressLease?.binding ? 1 : unboundMaxAttempts;
1063
1279
  const dedupeAllocatorEndpoints = rotatesRegistryChain;
1064
1280
  let lastError;
1065
1281
  for (let refreshAttempt = 0; refreshAttempt <= MAX_POLICY_PROXY_POOL_REFRESHES; refreshAttempt += 1) {
@@ -1075,8 +1291,9 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
1075
1291
  let fallbackSensitiveValues = [];
1076
1292
  let fallbackRequestUrl;
1077
1293
  let fallbackRedactedUrl;
1078
- const attemptStartedAt = Date.now();
1294
+ let attemptStartedAt = Date.now();
1079
1295
  let attemptRecorded = false;
1296
+ const attemptEgressGeneration = currentEgressGeneration();
1080
1297
  const recordProxyAttempt = (outcome, errorCode, status) => {
1081
1298
  if (attemptRecorded || !proxy)
1082
1299
  return;
@@ -1094,6 +1311,45 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
1094
1311
  durationMs: Date.now() - attemptStartedAt,
1095
1312
  });
1096
1313
  };
1314
+ // Shared tail of the first fetch, the automatic refetch, and an explicit replay: a
1315
+ // classified challenge is returned whatever `throwOnHttpError` says (documented on
1316
+ // StealthFetchOptions), any other non-2xx honours it, and the proxy attempt is
1317
+ // recorded once per delivered response.
1318
+ const finalizeAttempt = (attemptResponse, attemptNormalized) => {
1319
+ if (!attemptNormalized.challenge &&
1320
+ attemptResponse.status >= 400 &&
1321
+ options.throwOnHttpError !== false) {
1322
+ throw new TransportError(`Upstream request failed with status ${attemptResponse.status}`, { code: "upstream_http_error", status: attemptResponse.status });
1323
+ }
1324
+ recordProxyAttempt("ok", undefined, attemptResponse.status);
1325
+ return attemptNormalized;
1326
+ };
1327
+ const redactAttemptError = (error) => redactSensitiveError(error, serializedUrl?.sensitiveValues ?? fallbackSensitiveValues, serializedUrl?.requestUrl ?? fallbackRequestUrl, serializedUrl?.redactedUrl ?? fallbackRedactedUrl);
1328
+ // Cancellation and resolver failures leave the transport path here, redacted; any
1329
+ // other failure becomes the TransportError the retry decision reasons about.
1330
+ const normalizeAttemptError = (error) => {
1331
+ let normalizedError;
1332
+ try {
1333
+ throwIfAmbientAborted(clientOptions.signal);
1334
+ normalizedError = normalizeStealthTransportError(error);
1335
+ }
1336
+ catch (normalizationError) {
1337
+ if (normalizationError instanceof TransportError &&
1338
+ normalizationError.code === "transport_cancelled") {
1339
+ recordProxyAttempt("error", proxyAttemptErrorCode(normalizationError), proxyAttemptStatus(normalizationError));
1340
+ }
1341
+ throw redactAttemptError(normalizationError);
1342
+ }
1343
+ if (challengeSolveFailure && error === challengeSolveFailure) {
1344
+ throw redactAttemptError(challengeSolveFailure.error);
1345
+ }
1346
+ return normalizedError;
1347
+ };
1348
+ const recordAttemptFailure = (normalizedError) => {
1349
+ const redactedError = redactAttemptError(normalizedError);
1350
+ recordProxyAttempt("error", proxyAttemptErrorCode(redactedError), proxyAttemptStatus(redactedError));
1351
+ return redactedError;
1352
+ };
1097
1353
  try {
1098
1354
  throwIfAmbientAborted(clientOptions.signal);
1099
1355
  const sensitiveParams = normalizeSensitiveParams(options.sensitiveParams);
@@ -1123,12 +1379,13 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
1123
1379
  }
1124
1380
  const ignoreTlsErrors = Boolean(options.stealth?.insecureSkipVerify ??
1125
1381
  (!hasPolicyProxy && proxy && clientOptions.proxyStealth?.insecureSkipVerify));
1126
- serializedUrl = serializeRequestUrl(resolveUrl(baseUrl, url), options.params, sensitiveParams);
1382
+ serializedUrl = serializeRequestUrl(resolveRequestUrl(baseUrl, url), options.params, sensitiveParams);
1127
1383
  const { requestUrl } = serializedUrl;
1128
1384
  const wreq = await getWreqModule();
1129
1385
  const mapping = resolveWreqProfile(requestProfile, wreq.getProfiles());
1386
+ const emulationHeaders = Array.from(wreq.getEmulationHeaders(mapping.browser, mapping.os), ([name, value]) => [String(name), String(value)]);
1130
1387
  const chromeEmulationHeaders = mapping.browser.startsWith("chrome_")
1131
- ? Array.from(wreq.getEmulationHeaders(mapping.browser, mapping.os), ([name, value]) => [String(name), String(value)])
1388
+ ? emulationHeaders
1132
1389
  : undefined;
1133
1390
  const buildOrderedHeaders = chromeEmulationHeaders
1134
1391
  ? (currentUrl, currentMethod, currentBody, currentHeaders) => buildChromeHeaderTuples({
@@ -1139,6 +1396,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
1139
1396
  requestUrl: currentUrl,
1140
1397
  acceptLanguage: clientOptions.stealth?.acceptLanguage,
1141
1398
  requestClass: options.stealth?.requestClass,
1399
+ userActivation: options.stealth?.userActivation,
1142
1400
  })
1143
1401
  : undefined;
1144
1402
  const initialHeaders = normalizeHeaders({ ...(options.headers ?? {}) });
@@ -1147,61 +1405,261 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
1147
1405
  if (cookieHeader)
1148
1406
  initialHeaders.cookie = cookieHeader;
1149
1407
  }
1150
- const defaultHeaders = buildOrderedHeaders?.(requestUrl, method, options.body === undefined ? undefined : normalizeBody(options.body), initialHeaders);
1151
- const { normalized, response } = await withClient(requestProfile, proxy, ignoreTlsErrors, (transport) => fetchStealthRedirectChain(transport, cookieJar, requestUrl, method, options, clientOptions.signal, buildOrderedHeaders), clientOptions.signal, defaultHeaders);
1152
- if (proxy && isProxyConnectFailureResponse(response, normalized.body)) {
1153
- throw createProxyConnectFailureError(normalized.body);
1154
- }
1155
- if (response.status >= 400) {
1156
- if (proxy &&
1157
- usesPolicyAllocator &&
1158
- isProxyEdgeTlsRejectedResponse(response.status, [JSON.stringify(responseHeadersToRecord(response.headers)), normalized.body].join("\n"))) {
1159
- throw createProxyEdgeTlsRejectedError(response.status);
1408
+ const defaultHeaders = buildOrderedHeaders?.(requestUrl, method, requestBody, initialHeaders);
1409
+ const fetchOnBoundSession = (fetchUrl, fetchMethod, fetchOptions, fetchSignal, orderedHeaders = buildOrderedHeaders, sessionDefaultHeaders = defaultHeaders) => withClient(requestProfile, proxy, ignoreTlsErrors, (transport) => fetchStealthRedirectChain(transport, cookieJar, fetchUrl, fetchMethod, fetchOptions, fetchSignal, orderedHeaders), fetchSignal, sessionDefaultHeaders);
1410
+ const throwProxyTransportFault = (faultResponse, faultBody) => {
1411
+ if (!proxy)
1412
+ return;
1413
+ if (isProxyConnectFailureResponse(faultResponse, faultBody)) {
1414
+ throw createProxyConnectFailureError(faultBody);
1415
+ }
1416
+ if (faultResponse.status < 400)
1417
+ return;
1418
+ if (usesPolicyAllocator &&
1419
+ isProxyEdgeTlsRejectedResponse(faultResponse.status, [JSON.stringify(responseHeadersToRecord(faultResponse.headers)), faultBody].join("\n"))) {
1420
+ throw createProxyEdgeTlsRejectedError(faultResponse.status);
1160
1421
  }
1161
- if (proxy && isProxyAuthIpDeniedMessage(normalized.body)) {
1422
+ if (isProxyAuthIpDeniedMessage(faultBody)) {
1162
1423
  throw createProxyAuthIpDeniedError();
1163
1424
  }
1164
- if (proxy && isProxyEdgeAuthRejectedMessage(normalized.body)) {
1425
+ if (isProxyEdgeAuthRejectedMessage(faultBody)) {
1165
1426
  throw createProxyEdgeAuthRejectedError();
1166
1427
  }
1167
- if (proxy &&
1168
- isProxyPoolStaleStatus(response.status) &&
1169
- isProxyPoolStaleMessage(normalized.body)) {
1170
- throw createProxyPoolStaleError(response.status);
1428
+ if (isProxyPoolStaleStatus(faultResponse.status) &&
1429
+ isProxyPoolStaleMessage(faultBody)) {
1430
+ throw createProxyPoolStaleError(faultResponse.status);
1171
1431
  }
1172
- }
1173
- if (response.status >= 400 && options.throwOnHttpError !== false) {
1174
- throw new TransportError(`Upstream request failed with status ${response.status}`, {
1175
- code: "upstream_http_error",
1176
- status: response.status,
1432
+ };
1433
+ let { normalized, response } = await fetchOnBoundSession(requestUrl, method, options, clientOptions.signal);
1434
+ throwProxyTransportFault(response, normalized.body);
1435
+ // Commit only after the selected endpoint reached the origin. A failed
1436
+ // proxy attempt may rotate pool index; the successful exact endpoint is
1437
+ // the one sealed into the ceremony handle.
1438
+ bindCeremonyEgress(attemptProxy);
1439
+ const akamaiSbsd = clientOptions.stealth?.challengeRuntime?.akamaiSbsd;
1440
+ const detected = akamaiSbsd
1441
+ ? detectAkamaiSbsdChallenge(normalized, requestUrl, cookieJar, akamaiSbsd.allowedHosts, akamaiSbsdState)
1442
+ : undefined;
1443
+ if (detected && akamaiSbsd) {
1444
+ if (challengeSolveAttempted || challengeRefetchAttempted) {
1445
+ normalized.challenge = {
1446
+ challenge: detected,
1447
+ outcome: "challenge_persisted",
1448
+ };
1449
+ return finalizeAttempt(response, normalized);
1450
+ }
1451
+ const automaticReplayEligible = isProxyTransportRetryMethod(method, automaticChallengeRefetchPolicy, { body: requestBody, headers: options.headers });
1452
+ if (automaticReplayEligible && !akamaiSbsd.solve) {
1453
+ normalized.challenge = {
1454
+ challenge: detected,
1455
+ outcome: "resolver_unavailable",
1456
+ };
1457
+ return finalizeAttempt(response, normalized);
1458
+ }
1459
+ const emulationHeaderMap = new Map(emulationHeaders.map(([name, value]) => [name.toLowerCase(), value]));
1460
+ const initiatingHeaders = normalizeHeaders({ ...(options.headers ?? {}) });
1461
+ // Mirror what the protected request actually sent: `stealth.acceptLanguage` is
1462
+ // applied by the Chrome header builder only; Safari/Firefox sessions send the
1463
+ // emulation default.
1464
+ const sessionHeaders = {
1465
+ "User-Agent": requiredEmulationHeader(emulationHeaderMap, "user-agent"),
1466
+ "Accept-Language": initiatingHeaders["accept-language"] ??
1467
+ (chromeEmulationHeaders ? clientOptions.stealth?.acceptLanguage : undefined) ??
1468
+ requiredEmulationHeader(emulationHeaderMap, "accept-language"),
1469
+ };
1470
+ const resolverBuildHeaders = chromeEmulationHeaders
1471
+ ? (currentUrl, currentMethod, currentBody, currentHeaders) => buildChromeHeaderTuples({
1472
+ emulationHeaders: chromeEmulationHeaders,
1473
+ method: currentMethod,
1474
+ body: currentBody,
1475
+ headers: currentHeaders,
1476
+ requestUrl: currentUrl,
1477
+ acceptLanguage: clientOptions.stealth?.acceptLanguage,
1478
+ })
1479
+ : undefined;
1480
+ const resolverTransport = {
1481
+ sessionHeaders,
1482
+ getCookie(name, cookieUrl) {
1483
+ return cookieJar.get(name, cookieUrl);
1484
+ },
1485
+ async fetch(transportUrl, init) {
1486
+ const boundSignal = clientOptions.signal
1487
+ ? AbortSignal.any([clientOptions.signal, init.signal])
1488
+ : init.signal;
1489
+ const transportHeaders = { ...(init.headers ?? {}) };
1490
+ if (resolverBuildHeaders) {
1491
+ for (const name of Object.keys(transportHeaders)) {
1492
+ if (name.toLowerCase() === "user-agent") {
1493
+ delete transportHeaders[name];
1494
+ }
1495
+ }
1496
+ }
1497
+ // Session defaults come from this request's own shape, never from the
1498
+ // initiating request: its defaults carry the upstream Cookie header,
1499
+ // which wreq would otherwise merge into the Hyper /ip call.
1500
+ const result = await fetchOnBoundSession(transportUrl, init.method, {
1501
+ method: init.method,
1502
+ headers: transportHeaders,
1503
+ ...(init.body === undefined ? {} : { body: init.body }),
1504
+ redirect: "manual",
1505
+ throwOnHttpError: false,
1506
+ ...(init.maxBodyBytes === undefined
1507
+ ? {}
1508
+ : { maxBodyBytes: init.maxBodyBytes }),
1509
+ }, boundSignal, resolverBuildHeaders, resolverBuildHeaders?.(transportUrl, init.method, init.body, normalizeHeaders(transportHeaders)));
1510
+ return {
1511
+ status: result.normalized.status,
1512
+ headers: result.normalized.headers,
1513
+ body: result.normalized.body,
1514
+ cookies: resolverCookiesFromResponse(result.response, transportUrl),
1515
+ };
1516
+ },
1517
+ };
1518
+ // `options` is already the fetch-start snapshot; the replay gets its own copy
1519
+ // of the body bytes so the transport cannot alias the first send's buffer.
1520
+ const replayOptions = {
1521
+ ...options,
1522
+ ...(Buffer.isBuffer(requestBody) ? { body: Buffer.from(requestBody) } : {}),
1523
+ };
1524
+ const transactionKey = akamaiSbsdChallengeKey(detected, {
1525
+ wreqBrowser: mapping.browser,
1526
+ wreqOs: mapping.os,
1527
+ proxyUrl: proxy,
1177
1528
  });
1529
+ const challenged = { normalized, response };
1530
+ const solveAndReplay = async (explicitReplay) => {
1531
+ if (!akamaiSbsd.solve) {
1532
+ throw new SDKError("No resolver is available for the challenged request", {
1533
+ code: "RESOLVER_UNAVAILABLE",
1534
+ });
1535
+ }
1536
+ // Only a solve that will actually run on this session needs the profile match.
1537
+ assertAkamaiSbsdClientProfile(akamaiSbsd.clientProfile, requestProfile);
1538
+ // A safe request that already solved this exact challenge on this identity
1539
+ // left the session cookies valid: the explicit replay reuses them once
1540
+ // instead of paying for a second solve.
1541
+ const reuseCompletedSuccess = explicitReplay && akamaiSbsdState.completedSuccessKey === transactionKey;
1542
+ if (reuseCompletedSuccess)
1543
+ akamaiSbsdState.completedSuccessKey = undefined;
1544
+ let ownsTransaction = false;
1545
+ if (!reuseCompletedSuccess) {
1546
+ let transaction = akamaiSbsdState.transactions.get(transactionKey);
1547
+ if (!transaction) {
1548
+ akamaiSbsdState.completedSuccessKey = undefined;
1549
+ challengeSolveAttempted = true;
1550
+ ownsTransaction = true;
1551
+ // The solve spans several round trips, so it runs under the client's ambient
1552
+ // signal and the resolver's own timeouts, not this fetch's per-request `timeout`.
1553
+ transaction = {
1554
+ result: akamaiSbsd
1555
+ .solve(detected, resolverTransport, clientOptions.signal ?? new AbortController().signal)
1556
+ .then(() => ({ solved: true }), (error) => ({ solved: false, error }))
1557
+ .finally(() => {
1558
+ akamaiSbsdState.transactions.delete(transactionKey);
1559
+ }),
1560
+ };
1561
+ akamaiSbsdState.transactions.set(transactionKey, transaction);
1562
+ }
1563
+ const transactionResult = await transaction.result;
1564
+ if (!transactionResult.solved) {
1565
+ if (ownsTransaction) {
1566
+ // The proxy delivered the challenged response; the resolver failed.
1567
+ // Surface that failure as-is: it is not a transport fault to normalize
1568
+ // or retry.
1569
+ if (!explicitReplay) {
1570
+ recordProxyAttempt("ok", undefined, challenged.response.status);
1571
+ }
1572
+ challengeSolveFailure = { error: transactionResult.error };
1573
+ throw challengeSolveFailure;
1574
+ }
1575
+ return {
1576
+ normalized: {
1577
+ ...challenged.normalized,
1578
+ challenge: { challenge: detected, outcome: "solve_failed" },
1579
+ },
1580
+ response: challenged.response,
1581
+ };
1582
+ }
1583
+ }
1584
+ challengeRefetchAttempted = true;
1585
+ // An explicit replay is a new transport exchange on the bound endpoint and
1586
+ // gets its own proxy-attempt record timed from here, not from the initiating
1587
+ // fetch (the caller's think time in between is not transport duration); the
1588
+ // automatic refetch stays within the initiating attempt's record.
1589
+ if (explicitReplay) {
1590
+ // The solve above may have taken seconds; if another session of this client
1591
+ // moved the ceremony meanwhile, the mutation must not go out on the retired
1592
+ // endpoint.
1593
+ if (currentEgressGeneration() !== attemptEgressGeneration) {
1594
+ throw new SDKError("The ceremony's egress binding changed while the replay was being solved", { code: "REPLAY_SESSION_MISMATCH" });
1595
+ }
1596
+ attemptRecorded = false;
1597
+ attemptStartedAt = Date.now();
1598
+ }
1599
+ const replayed = await fetchOnBoundSession(requestUrl, method, replayOptions, clientOptions.signal);
1600
+ throwProxyTransportFault(replayed.response, replayed.normalized.body);
1601
+ const persisted = detectAkamaiSbsdChallenge(replayed.normalized, requestUrl, cookieJar, akamaiSbsd.allowedHosts, akamaiSbsdState);
1602
+ if (persisted) {
1603
+ replayed.normalized.challenge = {
1604
+ challenge: persisted,
1605
+ outcome: "challenge_persisted",
1606
+ };
1607
+ }
1608
+ else if (ownsTransaction && !explicitReplay) {
1609
+ akamaiSbsdState.completedSuccessKey = transactionKey;
1610
+ }
1611
+ return replayed;
1612
+ };
1613
+ if (!automaticReplayEligible) {
1614
+ normalized.challenge = {
1615
+ challenge: detected,
1616
+ outcome: "replay_required",
1617
+ };
1618
+ challengedReplays.set(normalized, {
1619
+ consumed: false,
1620
+ generation: attemptEgressGeneration,
1621
+ async replay() {
1622
+ try {
1623
+ const replayed = await solveAndReplay(true);
1624
+ return finalizeAttempt(replayed.response, replayed.normalized);
1625
+ }
1626
+ catch (error) {
1627
+ const normalizedError = normalizeAttemptError(error);
1628
+ releaseFailedCeremonyEgress(proxy, normalizedError);
1629
+ throw recordAttemptFailure(normalizedError);
1630
+ }
1631
+ },
1632
+ });
1633
+ return finalizeAttempt(response, normalized);
1634
+ }
1635
+ ({ normalized, response } = await solveAndReplay(false));
1178
1636
  }
1179
- recordProxyAttempt("ok", undefined, response.status);
1180
- return normalized;
1637
+ return finalizeAttempt(response, normalized);
1181
1638
  }
1182
1639
  catch (error) {
1183
- const sensitiveValues = serializedUrl?.sensitiveValues ?? fallbackSensitiveValues;
1184
- let normalizedError;
1185
- try {
1186
- throwIfAmbientAborted(clientOptions.signal);
1187
- normalizedError = normalizeStealthTransportError(error);
1188
- }
1189
- catch (normalizationError) {
1190
- const redactedNormalizationError = redactSensitiveError(normalizationError, sensitiveValues, serializedUrl?.requestUrl ?? fallbackRequestUrl, serializedUrl?.redactedUrl ?? fallbackRedactedUrl);
1191
- if (normalizationError instanceof TransportError &&
1192
- normalizationError.code === "transport_cancelled") {
1193
- recordProxyAttempt("error", proxyAttemptErrorCode(normalizationError), proxyAttemptStatus(normalizationError));
1194
- }
1195
- throw redactedNormalizationError;
1196
- }
1640
+ const normalizedError = normalizeAttemptError(error);
1197
1641
  const retryErrorCode = proxyAttemptErrorCode(normalizedError);
1198
1642
  const refreshableProxyError = isProxyPoolRefreshableError(normalizedError);
1199
1643
  const runProxyAuthDiagnostic = shouldRunProxyAuthDiagnostic(normalizedError);
1200
- normalizedError = redactSensitiveError(normalizedError, sensitiveValues, serializedUrl?.requestUrl ?? fallbackRequestUrl, serializedUrl?.redactedUrl ?? fallbackRedactedUrl);
1201
- recordProxyAttempt("error", proxyAttemptErrorCode(normalizedError), proxyAttemptStatus(normalizedError));
1202
- lastError = normalizedError;
1644
+ const redactedError = recordAttemptFailure(normalizedError);
1645
+ lastError = redactedError;
1646
+ const releasedBoundEgress = releaseFailedCeremonyEgress(proxy, normalizedError);
1647
+ if (releasedBoundEgress) {
1648
+ // The request now proceeds as an unbound one with the full rotation budget;
1649
+ // the attempt spent on the dead endpoint does not count against it.
1650
+ maxAttempts = attempt + 1 + unboundMaxAttempts;
1651
+ }
1652
+ if (challengeSolveAttempted || challengeRefetchAttempted) {
1653
+ // The single refetch is spent (owner or waiter of a shared solve): another
1654
+ // transport attempt would replay the original request and may rotate the
1655
+ // proxy the solved jar is bound to.
1656
+ throw redactedError;
1657
+ }
1203
1658
  if (proxy && rotatesRegistryChain && refreshableProxyError) {
1204
- stalePoolError = normalizedError;
1659
+ // Also the path a released bound endpoint takes after a proxy-level refusal:
1660
+ // the stale-pool bookkeeping here is what drives allocator invalidation and the
1661
+ // refresh pass when the fresh selection lands on the same endpoint again.
1662
+ stalePoolError = redactedError;
1205
1663
  if (runProxyAuthDiagnostic) {
1206
1664
  stalePoolDiagnosticProxy = proxy;
1207
1665
  }
@@ -1210,6 +1668,15 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
1210
1668
  }
1211
1669
  break;
1212
1670
  }
1671
+ if (releasedBoundEgress &&
1672
+ attempt + 1 < maxAttempts &&
1673
+ retryErrorCode === PROXY_CONNECT_FAILURE_CODE) {
1674
+ // The tunnel to the released endpoint never opened, so the origin never saw the
1675
+ // request: selecting and binding a fresh endpoint in this request is safe for
1676
+ // any method. Ambiguous failures (reset, timeout) fall through to the ordinary
1677
+ // method-aware retry decision below with the binding already released.
1678
+ continue;
1679
+ }
1213
1680
  // Cap the number of transport retries. For a policy-allocator chain,
1214
1681
  // every attempt resolves a *different* endpoint/vendor (poolIndex
1215
1682
  // rotates across the concatenated vendor pool spans), so a transport
@@ -1244,7 +1711,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
1244
1711
  throwIfAmbientAborted(clientOptions.signal);
1245
1712
  continue;
1246
1713
  }
1247
- throw normalizedError;
1714
+ throw redactedError;
1248
1715
  }
1249
1716
  }
1250
1717
  if (rotatesRegistryChain &&
@@ -1280,6 +1747,32 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
1280
1747
  throwIfAmbientAborted(clientOptions.signal);
1281
1748
  throw normalizeStealthTransportError(lastError);
1282
1749
  },
1750
+ async replayChallenged(challengedResponse) {
1751
+ // Lease expiry is evaluated when a fetch starts, not here: the replay belongs to the
1752
+ // exchange that was challenged on the bound endpoint (Akamai state is IP-bound), so a
1753
+ // lifetime boundary crossed during the solve does not move it elsewhere. The next
1754
+ // fetch drops the expired binding and rebinds.
1755
+ const replay = challengedReplays.get(challengedResponse);
1756
+ if (!replay || challengedResponse.challenge?.outcome !== "replay_required") {
1757
+ throw new SDKError("The challenged response does not belong to this stealth session", {
1758
+ code: "REPLAY_SESSION_MISMATCH",
1759
+ });
1760
+ }
1761
+ syncCeremonyEgress();
1762
+ if (replay.generation !== currentEgressGeneration()) {
1763
+ // The ceremony rebound to another egress after this request ran (in this session or
1764
+ // another of the same client); solving and replaying on the retired endpoint would
1765
+ // split the identity the handle names.
1766
+ throw new SDKError("The challenged response predates the ceremony's current egress binding", { code: "REPLAY_SESSION_MISMATCH" });
1767
+ }
1768
+ if (replay.consumed) {
1769
+ throw new SDKError("The challenged request has already used its one replay budget", {
1770
+ code: "REPLAY_ALREADY_ATTEMPTED",
1771
+ });
1772
+ }
1773
+ replay.consumed = true;
1774
+ return replay.replay();
1775
+ },
1283
1776
  cookies: cookieJar,
1284
1777
  redirects: {
1285
1778
  async run(options) {
@@ -1455,6 +1948,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
1455
1948
  },
1456
1949
  close() {
1457
1950
  closed = true;
1951
+ clearAkamaiSbsdState();
1458
1952
  for (const client of clients.values()) {
1459
1953
  void client.session
1460
1954
  .then((session) => session.close())