@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.
- package/AUTHORING.md +9 -0
- package/CHANGELOG.md +8 -0
- package/README.md +22 -5
- package/SUBMISSION.md +2 -1
- package/bin/apifuse-pack-types.ts +49 -0
- package/bin/apifuse-record.ts +6 -0
- package/dist/cli/templates/provider/README.md.tpl +19 -5
- package/dist/config/loader.d.ts +7 -0
- package/dist/config/loader.js +30 -6
- package/dist/declaration-validation.d.ts +2 -1
- package/dist/declaration-validation.js +39 -0
- package/dist/define.d.ts +5 -4
- package/dist/define.js +25 -1
- package/dist/engine.d.ts +16 -0
- package/dist/engine.js +58 -1
- package/dist/error-resolution.js +13 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +4 -2
- package/dist/lint.js +3 -3
- package/dist/provider.d.ts +3 -3
- package/dist/provider.js +2 -2
- package/dist/runtime/bounded-expiring-map.d.ts +21 -0
- package/dist/runtime/bounded-expiring-map.js +64 -0
- package/dist/runtime/cache.d.ts +2 -0
- package/dist/runtime/cache.js +8 -1
- package/dist/runtime/egress-lease.d.ts +63 -0
- package/dist/runtime/egress-lease.js +233 -0
- package/dist/runtime/http.js +1 -0
- package/dist/runtime/proxy-retry-policy.d.ts +4 -1
- package/dist/runtime/proxy-retry-policy.js +11 -2
- package/dist/runtime/request-telemetry.d.ts +7 -9
- package/dist/runtime/resolver-config.d.ts +7 -0
- package/dist/runtime/resolver-config.js +8 -0
- package/dist/runtime/resolver-public.d.ts +3 -1
- package/dist/runtime/resolver-public.js +1 -1
- package/dist/runtime/resolver-shared.d.ts +9 -0
- package/dist/runtime/resolver-shared.js +57 -0
- package/dist/runtime/resolver-telemetry.d.ts +134 -0
- package/dist/runtime/resolver-telemetry.js +204 -0
- package/dist/runtime/resolver-usage.d.ts +24 -0
- package/dist/runtime/resolver-usage.js +60 -0
- package/dist/runtime/resolver-vendors/bindings.d.ts +5 -0
- package/dist/runtime/resolver-vendors/bindings.js +5 -0
- package/dist/runtime/resolver-vendors/capsolver.d.ts +2 -2
- package/dist/runtime/resolver-vendors/capsolver.js +21 -9
- package/dist/runtime/resolver-vendors/hypersolutions.d.ts +27 -0
- package/dist/runtime/resolver-vendors/hypersolutions.js +369 -0
- package/dist/runtime/resolver-vendors/twocaptcha.d.ts +2 -2
- package/dist/runtime/resolver-vendors/twocaptcha.js +26 -10
- package/dist/runtime/resolver-vendors/types.d.ts +35 -5
- package/dist/runtime/resolver-vendors/types.js +20 -10
- package/dist/runtime/resolver.d.ts +5 -1
- package/dist/runtime/resolver.js +397 -109
- package/dist/runtime/stealth-akamai-sbsd.d.ts +54 -0
- package/dist/runtime/stealth-akamai-sbsd.js +142 -0
- package/dist/runtime/stealth.js +547 -53
- package/dist/schema.d.ts +9 -0
- package/dist/schema.js +16 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.js +1 -0
- package/dist/server/self-test.d.ts +4 -0
- package/dist/server/self-test.js +10 -1
- package/dist/server/serve-implementation.d.ts +7 -1
- package/dist/server/serve-implementation.js +181 -53
- package/dist/server/types.d.ts +6 -0
- package/dist/server/types.js +14 -0
- package/dist/stealth/profiles.d.ts +7 -1
- package/dist/stealth/profiles.js +9 -0
- package/dist/testing/run.js +6 -0
- package/dist/types.d.ts +115 -12
- package/package.json +1 -1
- package/src/cli/templates/provider/README.md.tpl +19 -5
- package/src/config/loader.ts +34 -6
- package/src/declaration-validation.ts +47 -1
- package/src/define.ts +46 -3
- package/src/engine.ts +76 -1
- package/src/error-resolution.ts +13 -0
- package/src/index.ts +32 -1
- package/src/lint.ts +3 -3
- package/src/provider.ts +9 -0
- package/src/runtime/bounded-expiring-map.ts +67 -0
- package/src/runtime/cache.ts +10 -1
- package/src/runtime/egress-lease.ts +336 -0
- package/src/runtime/http.ts +1 -0
- package/src/runtime/proxy-retry-policy.ts +12 -1
- package/src/runtime/request-telemetry.ts +5 -7
- package/src/runtime/resolver-config.ts +12 -0
- package/src/runtime/resolver-public.ts +21 -0
- package/src/runtime/resolver-shared.ts +75 -0
- package/src/runtime/resolver-telemetry.ts +362 -0
- package/src/runtime/resolver-usage.ts +96 -0
- package/src/runtime/resolver-vendors/bindings.ts +5 -0
- package/src/runtime/resolver-vendors/capsolver.ts +22 -3
- package/src/runtime/resolver-vendors/hypersolutions.ts +480 -0
- package/src/runtime/resolver-vendors/twocaptcha.ts +26 -12
- package/src/runtime/resolver-vendors/types.ts +60 -12
- package/src/runtime/resolver.ts +465 -124
- package/src/runtime/stealth-akamai-sbsd.ts +207 -0
- package/src/runtime/stealth.ts +727 -95
- package/src/schema.ts +17 -0
- package/src/server/index.ts +21 -0
- package/src/server/self-test.ts +13 -0
- package/src/server/serve-implementation.ts +268 -48
- package/src/server/types.ts +14 -0
- package/src/stealth/profiles.ts +10 -0
- package/src/testing/run.ts +6 -0
- package/src/types.ts +130 -22
package/dist/runtime/stealth.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
680
|
+
return Buffer.from(body);
|
|
615
681
|
}
|
|
616
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
?
|
|
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,
|
|
1151
|
-
const
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
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 (
|
|
1422
|
+
if (isProxyAuthIpDeniedMessage(faultBody)) {
|
|
1162
1423
|
throw createProxyAuthIpDeniedError();
|
|
1163
1424
|
}
|
|
1164
|
-
if (
|
|
1425
|
+
if (isProxyEdgeAuthRejectedMessage(faultBody)) {
|
|
1165
1426
|
throw createProxyEdgeAuthRejectedError();
|
|
1166
1427
|
}
|
|
1167
|
-
if (
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
throw createProxyPoolStaleError(response.status);
|
|
1428
|
+
if (isProxyPoolStaleStatus(faultResponse.status) &&
|
|
1429
|
+
isProxyPoolStaleMessage(faultBody)) {
|
|
1430
|
+
throw createProxyPoolStaleError(faultResponse.status);
|
|
1171
1431
|
}
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
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
|
-
|
|
1180
|
-
return normalized;
|
|
1637
|
+
return finalizeAttempt(response, normalized);
|
|
1181
1638
|
}
|
|
1182
1639
|
catch (error) {
|
|
1183
|
-
const
|
|
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
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
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
|
-
|
|
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
|
|
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())
|