@apifuse/provider-sdk 2.2.0-beta.45 → 2.2.0-beta.46

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/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @apifuse/provider-sdk Changelog
2
2
 
3
+ ## 2.2.0-beta.46
4
+
5
+ - Release candidate for main commit 0340fbc51b4ad71689bb1ec61b9b818dedda4587.
6
+
3
7
  ## 2.2.0-beta.45
4
8
 
5
9
  - Release candidate for main commit 5d337d9fc1c77cd814ec7a4e1aa44ba0f8b2a5a9.
@@ -4,6 +4,8 @@ import type { StealthClient, StealthResponse } from "../types.js";
4
4
  export declare const DEFAULT_PROFILE = "chrome-desktop";
5
5
  export type StealthClientOptions = ProxyResolutionOptions & {
6
6
  warn?: (message: string) => void;
7
+ /** Abort all requests issued by this client. */
8
+ signal?: AbortSignal;
7
9
  /**
8
10
  * Proxy-only stealth transport overrides. Use only for upstream proxy products
9
11
  * that terminate CONNECT with a private CA instead of tunneling the origin
@@ -206,11 +206,14 @@ function splitCombinedSetCookieHeader(headerValue) {
206
206
  return cookieStrings;
207
207
  }
208
208
  export async function normalizeResponse(response, requestUrl, maxBodyBytes) {
209
+ return normalizeResponseWithSignal(response, requestUrl, maxBodyBytes);
210
+ }
211
+ async function normalizeResponseWithSignal(response, requestUrl, maxBodyBytes, signal) {
209
212
  const headers = Object.fromEntries(response.headers.entries());
210
213
  const cookies = new StealthCookieJar(setCookieHeadersFromResponse(response.headers), response.url ?? requestUrl);
211
214
  const bodyBytes = maxBodyBytes === undefined
212
- ? await response.arrayBuffer()
213
- : await readResponseBodyWithLimit(response, maxBodyBytes);
215
+ ? await readResponseArrayBuffer(response, signal)
216
+ : await readResponseBodyWithLimit(response, maxBodyBytes, signal);
214
217
  const body = new TextDecoder().decode(bodyBytes);
215
218
  return {
216
219
  status: response.status,
@@ -236,6 +239,40 @@ export async function normalizeResponse(response, requestUrl, maxBodyBytes) {
236
239
  },
237
240
  };
238
241
  }
242
+ async function readResponseArrayBuffer(response, signal) {
243
+ if (!signal)
244
+ return response.arrayBuffer();
245
+ throwIfAmbientAborted(signal);
246
+ return new Promise((resolve, reject) => {
247
+ let settled = false;
248
+ const settle = (operation) => {
249
+ if (settled)
250
+ return;
251
+ settled = true;
252
+ signal.removeEventListener("abort", onAbort);
253
+ operation();
254
+ };
255
+ const onAbort = () => {
256
+ const error = toAmbientCancellationError(signal);
257
+ try {
258
+ void response.body?.cancel().catch(() => undefined);
259
+ }
260
+ catch {
261
+ // Preserve the cancellation error if accessing or cancelling the body fails.
262
+ }
263
+ settle(() => reject(error));
264
+ };
265
+ signal.addEventListener("abort", onAbort, { once: true });
266
+ try {
267
+ void response.arrayBuffer().then((body) => settle(() => resolve(body)), (error) => settle(() => reject(error)));
268
+ }
269
+ catch (error) {
270
+ settle(() => reject(error));
271
+ }
272
+ if (signal.aborted)
273
+ onAbort();
274
+ });
275
+ }
239
276
  function responseTooLargeError(maxBodyBytes, observedBytes) {
240
277
  return new TransportError(`Response body exceeded maxBodyBytes limit of ${maxBodyBytes} bytes (observed ${observedBytes} bytes)`, {
241
278
  code: "response_too_large",
@@ -251,7 +288,8 @@ function declaredContentLength(headers) {
251
288
  const parsed = Number(contentLength);
252
289
  return Number.isFinite(parsed) ? parsed : undefined;
253
290
  }
254
- async function readResponseBodyWithLimit(response, maxBodyBytes) {
291
+ async function readResponseBodyWithLimit(response, maxBodyBytes, signal) {
292
+ throwIfAmbientAborted(signal);
255
293
  const contentLength = declaredContentLength(response.headers);
256
294
  if (contentLength !== undefined && contentLength > maxBodyBytes) {
257
295
  await response.body?.cancel().catch(() => undefined);
@@ -269,7 +307,7 @@ async function readResponseBodyWithLimit(response, maxBodyBytes) {
269
307
  let receivedBytes = 0;
270
308
  try {
271
309
  while (true) {
272
- const { done, value } = await reader.read();
310
+ const { done, value } = await readResponseBodyChunk(reader, signal);
273
311
  if (done)
274
312
  break;
275
313
  if (!value)
@@ -285,6 +323,9 @@ async function readResponseBodyWithLimit(response, maxBodyBytes) {
285
323
  finally {
286
324
  reader.releaseLock();
287
325
  }
326
+ return concatenateResponseBodyChunks(chunks, receivedBytes);
327
+ }
328
+ function concatenateResponseBodyChunks(chunks, receivedBytes) {
288
329
  const bodyBytes = new Uint8Array(receivedBytes);
289
330
  let offset = 0;
290
331
  for (const chunk of chunks) {
@@ -293,6 +334,30 @@ async function readResponseBodyWithLimit(response, maxBodyBytes) {
293
334
  }
294
335
  return bodyBytes.buffer;
295
336
  }
337
+ function readResponseBodyChunk(reader, signal) {
338
+ if (!signal)
339
+ return reader.read();
340
+ throwIfAmbientAborted(signal);
341
+ return new Promise((resolve, reject) => {
342
+ let settled = false;
343
+ const settle = (operation) => {
344
+ if (settled)
345
+ return;
346
+ settled = true;
347
+ signal.removeEventListener("abort", onAbort);
348
+ operation();
349
+ };
350
+ const onAbort = () => {
351
+ const error = toAmbientCancellationError(signal);
352
+ void reader.cancel().catch(() => undefined);
353
+ settle(() => reject(error));
354
+ };
355
+ signal.addEventListener("abort", onAbort, { once: true });
356
+ void reader.read().then((chunk) => settle(() => resolve(chunk)), (error) => settle(() => reject(error)));
357
+ if (signal.aborted)
358
+ onAbort();
359
+ });
360
+ }
296
361
  function normalizeBody(body) {
297
362
  if (body === undefined) {
298
363
  return "";
@@ -425,8 +490,38 @@ function normalizeStealthTransportError(error) {
425
490
  cause: error instanceof Error ? error : undefined,
426
491
  });
427
492
  }
428
- function sleep(ms) {
429
- return new Promise((resolve) => setTimeout(resolve, ms));
493
+ function toAmbientCancellationError(signal, error = signal.reason) {
494
+ if (error instanceof TransportError && error.code === "transport_cancelled") {
495
+ return error;
496
+ }
497
+ return new TransportError("Request cancelled", {
498
+ code: "transport_cancelled",
499
+ status: 0,
500
+ retryable: false,
501
+ ...(error !== undefined
502
+ ? { cause: error instanceof Error ? error : new Error(String(error)) }
503
+ : {}),
504
+ });
505
+ }
506
+ function throwIfAmbientAborted(signal) {
507
+ if (signal?.aborted)
508
+ throw toAmbientCancellationError(signal);
509
+ }
510
+ function sleep(ms, signal) {
511
+ if (!signal)
512
+ return new Promise((resolve) => setTimeout(resolve, ms));
513
+ throwIfAmbientAborted(signal);
514
+ return new Promise((resolve, reject) => {
515
+ const onAbort = () => {
516
+ clearTimeout(timer);
517
+ reject(toAmbientCancellationError(signal));
518
+ };
519
+ const timer = setTimeout(() => {
520
+ signal.removeEventListener("abort", onAbort);
521
+ resolve();
522
+ }, ms);
523
+ signal.addEventListener("abort", onAbort, { once: true });
524
+ });
430
525
  }
431
526
  function normalizeMethod(method) {
432
527
  switch (method.toUpperCase()) {
@@ -480,7 +575,7 @@ function discardStealthRedirectBody(response) {
480
575
  // failure must not replace or delay that decision.
481
576
  }
482
577
  }
483
- async function fetchStealthRedirectChain(transport, cookieJar, requestUrl, method, options) {
578
+ async function fetchStealthRedirectChain(transport, cookieJar, requestUrl, method, options, signal) {
484
579
  let currentUrl = requestUrl;
485
580
  let currentMethod = method;
486
581
  let currentBody = options.body === undefined ? undefined : normalizeBody(options.body);
@@ -489,6 +584,7 @@ async function fetchStealthRedirectChain(transport, cookieJar, requestUrl, metho
489
584
  let response;
490
585
  const deadline = options.timeout ? performance.now() + options.timeout : undefined;
491
586
  while (true) {
587
+ throwIfAmbientAborted(signal);
492
588
  const headers = { ...currentHeaders };
493
589
  if (!hasHeader(headers, "Cookie")) {
494
590
  const cookieHeader = cookieJar.toHeader(currentUrl);
@@ -499,10 +595,12 @@ async function fetchStealthRedirectChain(transport, cookieJar, requestUrl, metho
499
595
  headers: normalizeHeaders(headers),
500
596
  method: currentMethod,
501
597
  redirect: "manual",
598
+ ...(signal ? { signal } : {}),
502
599
  };
503
600
  if (currentBody !== undefined)
504
601
  requestInit.body = currentBody;
505
602
  await transport.clearCookies();
603
+ throwIfAmbientAborted(signal);
506
604
  const remainingTimeout = deadline === undefined ? undefined : Math.ceil(deadline - performance.now());
507
605
  if (remainingTimeout !== undefined && remainingTimeout <= 0) {
508
606
  throw new TransportError("Request timed out", {
@@ -513,6 +611,10 @@ async function fetchStealthRedirectChain(transport, cookieJar, requestUrl, metho
513
611
  if (remainingTimeout !== undefined)
514
612
  requestInit.timeout = remainingTimeout;
515
613
  response = await transport.fetch(currentUrl, requestInit);
614
+ if (signal?.aborted) {
615
+ discardStealthRedirectBody(response);
616
+ throw toAmbientCancellationError(signal);
617
+ }
516
618
  cookieJar.setFromCookieStrings(setCookieHeadersFromResponse(response.headers), response.url ?? currentUrl);
517
619
  if (!isRedirectStatus(response.status) || options.redirect === "manual")
518
620
  break;
@@ -541,7 +643,7 @@ async function fetchStealthRedirectChain(transport, cookieJar, requestUrl, metho
541
643
  currentUrl = nextUrl;
542
644
  followedHops += 1;
543
645
  }
544
- const normalized = await normalizeResponse(response, currentUrl, options.maxBodyBytes);
646
+ const normalized = await normalizeResponseWithSignal(response, currentUrl, options.maxBodyBytes, signal);
545
647
  if (followedHops > 0)
546
648
  normalized.redirected = true;
547
649
  return { normalized, response };
@@ -579,20 +681,56 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
579
681
  }
580
682
  return entry;
581
683
  }
582
- async function withClient(profileName, proxyUrl, ignoreTlsErrors, operation) {
684
+ async function withClient(profileName, proxyUrl, ignoreTlsErrors, operation, signal) {
685
+ throwIfAmbientAborted(signal);
583
686
  const entry = await getClientEntry(profileName, proxyUrl, ignoreTlsErrors);
687
+ throwIfAmbientAborted(signal);
584
688
  const previous = entry.tail;
585
689
  let release;
586
690
  entry.tail = new Promise((resolve) => {
587
691
  release = resolve;
588
692
  });
589
- await previous;
693
+ let acquired = false;
590
694
  try {
591
- return await operation(await entry.session);
695
+ await waitForClientTurn(previous, signal);
696
+ acquired = true;
697
+ throwIfAmbientAborted(signal);
698
+ const client = await entry.session;
699
+ throwIfAmbientAborted(signal);
700
+ const result = await operation(client);
701
+ throwIfAmbientAborted(signal);
702
+ return result;
592
703
  }
593
704
  finally {
594
- release();
705
+ if (acquired) {
706
+ release();
707
+ }
708
+ else {
709
+ void previous.then(release, release);
710
+ }
711
+ }
712
+ }
713
+ async function waitForClientTurn(previous, signal) {
714
+ if (!signal) {
715
+ await previous;
716
+ return;
595
717
  }
718
+ throwIfAmbientAborted(signal);
719
+ await new Promise((resolve, reject) => {
720
+ let settled = false;
721
+ const settle = (operation) => {
722
+ if (settled)
723
+ return;
724
+ settled = true;
725
+ signal.removeEventListener("abort", onAbort);
726
+ operation();
727
+ };
728
+ const onAbort = () => settle(() => reject(toAmbientCancellationError(signal)));
729
+ signal.addEventListener("abort", onAbort, { once: true });
730
+ void previous.then(() => settle(resolve), (error) => settle(() => reject(error)));
731
+ if (signal.aborted)
732
+ onAbort();
733
+ });
596
734
  }
597
735
  async function resolveRequestProxy(options, proxyAttempt, refreshEpoch) {
598
736
  const resolvedProxy = await resolveProxyConfigAsync({
@@ -646,6 +784,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
646
784
  throw redactSensitiveRequestError(error, url, options.sensitiveParams);
647
785
  }
648
786
  })();
787
+ throwIfAmbientAborted(clientOptions.signal);
649
788
  const hasPolicyProxy = isPolicyManagedProxy(clientOptions);
650
789
  const usesPolicyAllocator = hasPolicyProxy && !options.proxy && !clientOptions.proxy;
651
790
  const retryAttemptCap = Math.max(1, stealthRetryOptions?.attempts ?? 1);
@@ -675,6 +814,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
675
814
  let stalePoolDiagnosticProxy;
676
815
  const attemptedProxies = new Set();
677
816
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
817
+ throwIfAmbientAborted(clientOptions.signal);
678
818
  let proxy;
679
819
  let attemptProxy;
680
820
  // Reuse the exact serialization used by this outbound attempt in its catch path.
@@ -702,6 +842,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
702
842
  });
703
843
  };
704
844
  try {
845
+ throwIfAmbientAborted(clientOptions.signal);
705
846
  const sensitiveParams = normalizeSensitiveParams(options.sensitiveParams);
706
847
  const structural = redactUrlQueryParams(url, Object.keys(sensitiveParams ?? {}));
707
848
  fallbackSensitiveValues = [
@@ -714,6 +855,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
714
855
  fallbackRedactedUrl = structural.redactedUrl;
715
856
  assertNoUnsupportedFingerprintOverrides(options);
716
857
  attemptProxy = await resolveRequestProxy(options, attempt, refreshAttempt);
858
+ throwIfAmbientAborted(clientOptions.signal);
717
859
  proxy = attemptProxy.url;
718
860
  if (proxy && dedupeAllocatorEndpoints) {
719
861
  // An under-filled allocation repeats endpoints (via the modulo
@@ -731,7 +873,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
731
873
  const profileName = options.profile ?? defaultProfile;
732
874
  serializedUrl = serializeRequestUrl(resolveUrl(baseUrl, url), options.params, sensitiveParams);
733
875
  const { requestUrl } = serializedUrl;
734
- const { normalized, response } = await withClient(profileName, proxy, ignoreTlsErrors, (transport) => fetchStealthRedirectChain(transport, cookieJar, requestUrl, method, options));
876
+ const { normalized, response } = await withClient(profileName, proxy, ignoreTlsErrors, (transport) => fetchStealthRedirectChain(transport, cookieJar, requestUrl, method, options, clientOptions.signal), clientOptions.signal);
735
877
  if (proxy && isProxyConnectFailureResponse(response, normalized.body)) {
736
878
  throw createProxyConnectFailureError(normalized.body);
737
879
  }
@@ -766,10 +908,16 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
766
908
  const sensitiveValues = serializedUrl?.sensitiveValues ?? fallbackSensitiveValues;
767
909
  let normalizedError;
768
910
  try {
911
+ throwIfAmbientAborted(clientOptions.signal);
769
912
  normalizedError = normalizeStealthTransportError(error);
770
913
  }
771
914
  catch (normalizationError) {
772
- throw redactSensitiveError(normalizationError, sensitiveValues, serializedUrl?.requestUrl ?? fallbackRequestUrl, serializedUrl?.redactedUrl ?? fallbackRedactedUrl);
915
+ const redactedNormalizationError = redactSensitiveError(normalizationError, sensitiveValues, serializedUrl?.requestUrl ?? fallbackRequestUrl, serializedUrl?.redactedUrl ?? fallbackRedactedUrl);
916
+ if (normalizationError instanceof TransportError &&
917
+ normalizationError.code === "transport_cancelled") {
918
+ recordProxyAttempt("error", proxyAttemptErrorCode(normalizationError), proxyAttemptStatus(normalizationError));
919
+ }
920
+ throw redactedNormalizationError;
773
921
  }
774
922
  const retryErrorCode = proxyAttemptErrorCode(normalizedError);
775
923
  const refreshableProxyError = isProxyPoolRefreshableError(normalizedError);
@@ -816,8 +964,9 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
816
964
  proxyUsed: Boolean(proxy),
817
965
  })) {
818
966
  if (stealthRetryOptions) {
819
- await sleep(computeProxyTransportRetryDelayMs(stealthRetryOptions, attempt + 1));
967
+ await sleep(computeProxyTransportRetryDelayMs(stealthRetryOptions, attempt + 1), clientOptions.signal);
820
968
  }
969
+ throwIfAmbientAborted(clientOptions.signal);
821
970
  continue;
822
971
  }
823
972
  throw normalizedError;
@@ -826,11 +975,13 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
826
975
  if (rotatesRegistryChain &&
827
976
  stalePoolError &&
828
977
  refreshAttempt < MAX_POLICY_PROXY_POOL_REFRESHES) {
978
+ throwIfAmbientAborted(clientOptions.signal);
829
979
  await invalidateProxyResolutionCacheAsync({
830
980
  proxyPolicy: clientOptions.proxyPolicy,
831
981
  upstream: clientOptions.upstream,
832
982
  affinityKey: clientOptions.affinityKey,
833
983
  });
984
+ throwIfAmbientAborted(clientOptions.signal);
834
985
  continue;
835
986
  }
836
987
  const proxyAuthDiagnostic = stalePoolError && stalePoolDiagnosticProxy
@@ -851,6 +1002,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
851
1002
  }
852
1003
  break;
853
1004
  }
1005
+ throwIfAmbientAborted(clientOptions.signal);
854
1006
  throw normalizeStealthTransportError(lastError);
855
1007
  },
856
1008
  cookies: cookieJar,
@@ -1043,16 +1195,20 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
1043
1195
  async function classifyProxyAuthDiagnostic(profileName, proxy) {
1044
1196
  try {
1045
1197
  return await withClient(profileName, proxy, false, async (client) => {
1198
+ throwIfAmbientAborted(clientOptions.signal);
1046
1199
  await client.clearCookies();
1200
+ throwIfAmbientAborted(clientOptions.signal);
1047
1201
  const response = await client.fetch(PROXY_AUTH_DIAGNOSTIC_URL, {
1048
1202
  method: "GET",
1049
1203
  timeout: PROXY_AUTH_DIAGNOSTIC_TIMEOUT_MS,
1204
+ ...(clientOptions.signal ? { signal: clientOptions.signal } : {}),
1050
1205
  });
1051
- const normalized = await normalizeResponse(response);
1206
+ const normalized = await normalizeResponseWithSignal(response, undefined, undefined, clientOptions.signal);
1052
1207
  return classifyProxyAuthDiagnosticMessage(normalized.body);
1053
- });
1208
+ }, clientOptions.signal);
1054
1209
  }
1055
1210
  catch (error) {
1211
+ throwIfAmbientAborted(clientOptions.signal);
1056
1212
  const message = error instanceof Error
1057
1213
  ? [error.message, error.cause instanceof Error ? error.cause.message : ""]
1058
1214
  .filter(Boolean)
@@ -409,6 +409,7 @@ function createProviderContext(provider, request, operationId, options, state =
409
409
  upstream: proxyClientOptions.upstream,
410
410
  affinityKey: proxyClientOptions.affinityKey,
411
411
  telemetry: proxyTelemetry,
412
+ ...(signal ? { signal } : {}),
412
413
  };
413
414
  const { capabilityModules } = options;
414
415
  const logStealthCleanupError = (error) => logProviderCleanupError(options.logger, provider, "operation", operationId, request.requestId, "stealth", error);
@@ -566,6 +567,7 @@ function createAuthFlowContext(provider, request, options, state, proxyTelemetry
566
567
  upstream: proxyClientOptions.upstream,
567
568
  affinityKey: proxyClientOptions.affinityKey,
568
569
  telemetry: proxyTelemetry,
570
+ ...(signal ? { signal } : {}),
569
571
  };
570
572
  const { capabilityModules } = options;
571
573
  const logStealthCleanupError = (error) => logProviderCleanupError(options.logger, provider, "auth", "flow", request.requestId, "stealth", error);
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.2.0-beta.45",
2
+ "version": "2.2.0-beta.46",
3
3
  "name": "@apifuse/provider-sdk",
4
4
  "private": false,
5
5
  "type": "module",
@@ -101,6 +101,8 @@ function sensitiveQueryParamNames(url: string): string[] {
101
101
 
102
102
  export type StealthClientOptions = ProxyResolutionOptions & {
103
103
  warn?: (message: string) => void;
104
+ /** Abort all requests issued by this client. */
105
+ signal?: AbortSignal;
104
106
  /**
105
107
  * Proxy-only stealth transport overrides. Use only for upstream proxy products
106
108
  * that terminate CONNECT with a private CA instead of tunneling the origin
@@ -360,6 +362,15 @@ export async function normalizeResponse(
360
362
  response: StealthTransportResponse,
361
363
  requestUrl?: string,
362
364
  maxBodyBytes?: number,
365
+ ): Promise<StealthResponse> {
366
+ return normalizeResponseWithSignal(response, requestUrl, maxBodyBytes);
367
+ }
368
+
369
+ async function normalizeResponseWithSignal(
370
+ response: StealthTransportResponse,
371
+ requestUrl?: string,
372
+ maxBodyBytes?: number,
373
+ signal?: AbortSignal,
363
374
  ): Promise<StealthResponse> {
364
375
  const headers = Object.fromEntries(response.headers.entries());
365
376
  const cookies = new StealthCookieJar(
@@ -368,8 +379,8 @@ export async function normalizeResponse(
368
379
  );
369
380
  const bodyBytes =
370
381
  maxBodyBytes === undefined
371
- ? await response.arrayBuffer()
372
- : await readResponseBodyWithLimit(response, maxBodyBytes);
382
+ ? await readResponseArrayBuffer(response, signal)
383
+ : await readResponseBodyWithLimit(response, maxBodyBytes, signal);
373
384
  const body = new TextDecoder().decode(bodyBytes);
374
385
 
375
386
  return {
@@ -397,6 +408,42 @@ export async function normalizeResponse(
397
408
  };
398
409
  }
399
410
 
411
+ async function readResponseArrayBuffer(
412
+ response: StealthTransportResponse,
413
+ signal?: AbortSignal,
414
+ ): Promise<ArrayBuffer> {
415
+ if (!signal) return response.arrayBuffer();
416
+ throwIfAmbientAborted(signal);
417
+ return new Promise((resolve, reject) => {
418
+ let settled = false;
419
+ const settle = (operation: () => void) => {
420
+ if (settled) return;
421
+ settled = true;
422
+ signal.removeEventListener("abort", onAbort);
423
+ operation();
424
+ };
425
+ const onAbort = () => {
426
+ const error = toAmbientCancellationError(signal);
427
+ try {
428
+ void response.body?.cancel().catch(() => undefined);
429
+ } catch {
430
+ // Preserve the cancellation error if accessing or cancelling the body fails.
431
+ }
432
+ settle(() => reject(error));
433
+ };
434
+ signal.addEventListener("abort", onAbort, { once: true });
435
+ try {
436
+ void response.arrayBuffer().then(
437
+ (body) => settle(() => resolve(body)),
438
+ (error) => settle(() => reject(error)),
439
+ );
440
+ } catch (error) {
441
+ settle(() => reject(error));
442
+ }
443
+ if (signal.aborted) onAbort();
444
+ });
445
+ }
446
+
400
447
  function responseTooLargeError(maxBodyBytes: number, observedBytes: number): TransportError {
401
448
  return new TransportError(
402
449
  `Response body exceeded maxBodyBytes limit of ${maxBodyBytes} bytes (observed ${observedBytes} bytes)`,
@@ -419,7 +466,9 @@ function declaredContentLength(headers: StealthTransportHeaders): number | undef
419
466
  async function readResponseBodyWithLimit(
420
467
  response: StealthTransportResponse,
421
468
  maxBodyBytes: number,
469
+ signal?: AbortSignal,
422
470
  ): Promise<ArrayBuffer> {
471
+ throwIfAmbientAborted(signal);
423
472
  const contentLength = declaredContentLength(response.headers);
424
473
  if (contentLength !== undefined && contentLength > maxBodyBytes) {
425
474
  await response.body?.cancel().catch(() => undefined);
@@ -439,7 +488,7 @@ async function readResponseBodyWithLimit(
439
488
  let receivedBytes = 0;
440
489
  try {
441
490
  while (true) {
442
- const { done, value } = await reader.read();
491
+ const { done, value } = await readResponseBodyChunk(reader, signal);
443
492
  if (done) break;
444
493
  if (!value) continue;
445
494
  receivedBytes += value.byteLength;
@@ -453,6 +502,13 @@ async function readResponseBodyWithLimit(
453
502
  reader.releaseLock();
454
503
  }
455
504
 
505
+ return concatenateResponseBodyChunks(chunks, receivedBytes);
506
+ }
507
+
508
+ function concatenateResponseBodyChunks(
509
+ chunks: readonly Uint8Array[],
510
+ receivedBytes: number,
511
+ ): ArrayBuffer {
456
512
  const bodyBytes = new Uint8Array(receivedBytes);
457
513
  let offset = 0;
458
514
  for (const chunk of chunks) {
@@ -462,6 +518,34 @@ async function readResponseBodyWithLimit(
462
518
  return bodyBytes.buffer;
463
519
  }
464
520
 
521
+ function readResponseBodyChunk(
522
+ reader: ReturnType<StealthTransportBody["getReader"]>,
523
+ signal?: AbortSignal,
524
+ ): Promise<{ done: boolean; value?: Uint8Array }> {
525
+ if (!signal) return reader.read();
526
+ throwIfAmbientAborted(signal);
527
+ return new Promise((resolve, reject) => {
528
+ let settled = false;
529
+ const settle = (operation: () => void) => {
530
+ if (settled) return;
531
+ settled = true;
532
+ signal.removeEventListener("abort", onAbort);
533
+ operation();
534
+ };
535
+ const onAbort = () => {
536
+ const error = toAmbientCancellationError(signal);
537
+ void reader.cancel().catch(() => undefined);
538
+ settle(() => reject(error));
539
+ };
540
+ signal.addEventListener("abort", onAbort, { once: true });
541
+ void reader.read().then(
542
+ (chunk) => settle(() => resolve(chunk)),
543
+ (error) => settle(() => reject(error)),
544
+ );
545
+ if (signal.aborted) onAbort();
546
+ });
547
+ }
548
+
465
549
  function normalizeBody(body: StealthFetchOptions["body"]): string {
466
550
  if (body === undefined) {
467
551
  return "";
@@ -627,8 +711,41 @@ function normalizeStealthTransportError(error: unknown): TransportError {
627
711
  });
628
712
  }
629
713
 
630
- function sleep(ms: number): Promise<void> {
631
- return new Promise((resolve) => setTimeout(resolve, ms));
714
+ function toAmbientCancellationError(
715
+ signal: AbortSignal,
716
+ error: unknown = signal.reason,
717
+ ): TransportError {
718
+ if (error instanceof TransportError && error.code === "transport_cancelled") {
719
+ return error;
720
+ }
721
+ return new TransportError("Request cancelled", {
722
+ code: "transport_cancelled",
723
+ status: 0,
724
+ retryable: false,
725
+ ...(error !== undefined
726
+ ? { cause: error instanceof Error ? error : new Error(String(error)) }
727
+ : {}),
728
+ });
729
+ }
730
+
731
+ function throwIfAmbientAborted(signal: AbortSignal | undefined): void {
732
+ if (signal?.aborted) throw toAmbientCancellationError(signal);
733
+ }
734
+
735
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
736
+ if (!signal) return new Promise((resolve) => setTimeout(resolve, ms));
737
+ throwIfAmbientAborted(signal);
738
+ return new Promise((resolve, reject) => {
739
+ const onAbort = () => {
740
+ clearTimeout(timer);
741
+ reject(toAmbientCancellationError(signal));
742
+ };
743
+ const timer = setTimeout(() => {
744
+ signal.removeEventListener("abort", onAbort);
745
+ resolve();
746
+ }, ms);
747
+ signal.addEventListener("abort", onAbort, { once: true });
748
+ });
632
749
  }
633
750
 
634
751
  function normalizeMethod(method: HttpMethod | string): StealthMethod {
@@ -693,6 +810,7 @@ async function fetchStealthRedirectChain(
693
810
  requestUrl: string,
694
811
  method: StealthMethod,
695
812
  options: StealthFetchOptions,
813
+ signal?: AbortSignal,
696
814
  ): Promise<{ normalized: StealthResponse; response: StealthTransportResponse }> {
697
815
  let currentUrl = requestUrl;
698
816
  let currentMethod = method;
@@ -703,6 +821,7 @@ async function fetchStealthRedirectChain(
703
821
  const deadline = options.timeout ? performance.now() + options.timeout : undefined;
704
822
 
705
823
  while (true) {
824
+ throwIfAmbientAborted(signal);
706
825
  const headers = { ...currentHeaders };
707
826
  if (!hasHeader(headers, "Cookie")) {
708
827
  const cookieHeader = cookieJar.toHeader(currentUrl);
@@ -712,10 +831,12 @@ async function fetchStealthRedirectChain(
712
831
  headers: normalizeHeaders(headers),
713
832
  method: currentMethod,
714
833
  redirect: "manual",
834
+ ...(signal ? { signal } : {}),
715
835
  };
716
836
  if (currentBody !== undefined) requestInit.body = currentBody;
717
837
 
718
838
  await transport.clearCookies();
839
+ throwIfAmbientAborted(signal);
719
840
  const remainingTimeout =
720
841
  deadline === undefined ? undefined : Math.ceil(deadline - performance.now());
721
842
  if (remainingTimeout !== undefined && remainingTimeout <= 0) {
@@ -726,6 +847,10 @@ async function fetchStealthRedirectChain(
726
847
  }
727
848
  if (remainingTimeout !== undefined) requestInit.timeout = remainingTimeout;
728
849
  response = await transport.fetch(currentUrl, requestInit);
850
+ if (signal?.aborted) {
851
+ discardStealthRedirectBody(response);
852
+ throw toAmbientCancellationError(signal);
853
+ }
729
854
  cookieJar.setFromCookieStrings(
730
855
  setCookieHeadersFromResponse(response.headers),
731
856
  response.url ?? currentUrl,
@@ -764,7 +889,12 @@ async function fetchStealthRedirectChain(
764
889
  followedHops += 1;
765
890
  }
766
891
 
767
- const normalized = await normalizeResponse(response, currentUrl, options.maxBodyBytes);
892
+ const normalized = await normalizeResponseWithSignal(
893
+ response,
894
+ currentUrl,
895
+ options.maxBodyBytes,
896
+ signal,
897
+ );
768
898
  if (followedHops > 0) normalized.redirected = true;
769
899
  return { normalized, response };
770
900
  }
@@ -819,19 +949,57 @@ function createSessionFetcher(
819
949
  proxyUrl: string | undefined,
820
950
  ignoreTlsErrors: boolean,
821
951
  operation: (client: WreqSession) => Promise<T>,
952
+ signal?: AbortSignal,
822
953
  ): Promise<T> {
954
+ throwIfAmbientAborted(signal);
823
955
  const entry = await getClientEntry(profileName, proxyUrl, ignoreTlsErrors);
956
+ throwIfAmbientAborted(signal);
824
957
  const previous = entry.tail;
825
958
  let release!: () => void;
826
959
  entry.tail = new Promise<void>((resolve) => {
827
960
  release = resolve;
828
961
  });
829
- await previous;
962
+ let acquired = false;
830
963
  try {
831
- return await operation(await entry.session);
964
+ await waitForClientTurn(previous, signal);
965
+ acquired = true;
966
+ throwIfAmbientAborted(signal);
967
+ const client = await entry.session;
968
+ throwIfAmbientAborted(signal);
969
+ const result = await operation(client);
970
+ throwIfAmbientAborted(signal);
971
+ return result;
832
972
  } finally {
833
- release();
973
+ if (acquired) {
974
+ release();
975
+ } else {
976
+ void previous.then(release, release);
977
+ }
978
+ }
979
+ }
980
+
981
+ async function waitForClientTurn(previous: Promise<void>, signal?: AbortSignal): Promise<void> {
982
+ if (!signal) {
983
+ await previous;
984
+ return;
834
985
  }
986
+ throwIfAmbientAborted(signal);
987
+ await new Promise<void>((resolve, reject) => {
988
+ let settled = false;
989
+ const settle = (operation: () => void) => {
990
+ if (settled) return;
991
+ settled = true;
992
+ signal.removeEventListener("abort", onAbort);
993
+ operation();
994
+ };
995
+ const onAbort = () => settle(() => reject(toAmbientCancellationError(signal)));
996
+ signal.addEventListener("abort", onAbort, { once: true });
997
+ void previous.then(
998
+ () => settle(resolve),
999
+ (error) => settle(() => reject(error)),
1000
+ );
1001
+ if (signal.aborted) onAbort();
1002
+ });
835
1003
  }
836
1004
 
837
1005
  async function resolveRequestProxy(
@@ -893,6 +1061,7 @@ function createSessionFetcher(
893
1061
  throw redactSensitiveRequestError(error, url, options.sensitiveParams);
894
1062
  }
895
1063
  })();
1064
+ throwIfAmbientAborted(clientOptions.signal);
896
1065
  const hasPolicyProxy = isPolicyManagedProxy(clientOptions);
897
1066
  const usesPolicyAllocator = hasPolicyProxy && !options.proxy && !clientOptions.proxy;
898
1067
  const retryAttemptCap = Math.max(1, stealthRetryOptions?.attempts ?? 1);
@@ -933,6 +1102,7 @@ function createSessionFetcher(
933
1102
  const attemptedProxies = new Set<string>();
934
1103
 
935
1104
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
1105
+ throwIfAmbientAborted(clientOptions.signal);
936
1106
  let proxy: string | undefined;
937
1107
  let attemptProxy: ResolvedAttemptProxy | undefined;
938
1108
  // Reuse the exact serialization used by this outbound attempt in its catch path.
@@ -963,6 +1133,7 @@ function createSessionFetcher(
963
1133
  });
964
1134
  };
965
1135
  try {
1136
+ throwIfAmbientAborted(clientOptions.signal);
966
1137
  const sensitiveParams = normalizeSensitiveParams(options.sensitiveParams);
967
1138
  const structural = redactUrlQueryParams(url, Object.keys(sensitiveParams ?? {}));
968
1139
  fallbackSensitiveValues = [
@@ -975,6 +1146,7 @@ function createSessionFetcher(
975
1146
  fallbackRedactedUrl = structural.redactedUrl;
976
1147
  assertNoUnsupportedFingerprintOverrides(options);
977
1148
  attemptProxy = await resolveRequestProxy(options, attempt, refreshAttempt);
1149
+ throwIfAmbientAborted(clientOptions.signal);
978
1150
  proxy = attemptProxy.url;
979
1151
  if (proxy && dedupeAllocatorEndpoints) {
980
1152
  // An under-filled allocation repeats endpoints (via the modulo
@@ -1003,7 +1175,15 @@ function createSessionFetcher(
1003
1175
  proxy,
1004
1176
  ignoreTlsErrors,
1005
1177
  (transport) =>
1006
- fetchStealthRedirectChain(transport, cookieJar, requestUrl, method, options),
1178
+ fetchStealthRedirectChain(
1179
+ transport,
1180
+ cookieJar,
1181
+ requestUrl,
1182
+ method,
1183
+ options,
1184
+ clientOptions.signal,
1185
+ ),
1186
+ clientOptions.signal,
1007
1187
  );
1008
1188
 
1009
1189
  if (proxy && isProxyConnectFailureResponse(response, normalized.body)) {
@@ -1051,14 +1231,26 @@ function createSessionFetcher(
1051
1231
  const sensitiveValues = serializedUrl?.sensitiveValues ?? fallbackSensitiveValues;
1052
1232
  let normalizedError: TransportError;
1053
1233
  try {
1234
+ throwIfAmbientAborted(clientOptions.signal);
1054
1235
  normalizedError = normalizeStealthTransportError(error);
1055
1236
  } catch (normalizationError) {
1056
- throw redactSensitiveError(
1237
+ const redactedNormalizationError = redactSensitiveError(
1057
1238
  normalizationError,
1058
1239
  sensitiveValues,
1059
1240
  serializedUrl?.requestUrl ?? fallbackRequestUrl,
1060
1241
  serializedUrl?.redactedUrl ?? fallbackRedactedUrl,
1061
1242
  );
1243
+ if (
1244
+ normalizationError instanceof TransportError &&
1245
+ normalizationError.code === "transport_cancelled"
1246
+ ) {
1247
+ recordProxyAttempt(
1248
+ "error",
1249
+ proxyAttemptErrorCode(normalizationError),
1250
+ proxyAttemptStatus(normalizationError),
1251
+ );
1252
+ }
1253
+ throw redactedNormalizationError;
1062
1254
  }
1063
1255
  const retryErrorCode = proxyAttemptErrorCode(normalizedError);
1064
1256
  const refreshableProxyError = isProxyPoolRefreshableError(normalizedError);
@@ -1116,8 +1308,12 @@ function createSessionFetcher(
1116
1308
  })
1117
1309
  ) {
1118
1310
  if (stealthRetryOptions) {
1119
- await sleep(computeProxyTransportRetryDelayMs(stealthRetryOptions!, attempt + 1));
1311
+ await sleep(
1312
+ computeProxyTransportRetryDelayMs(stealthRetryOptions, attempt + 1),
1313
+ clientOptions.signal,
1314
+ );
1120
1315
  }
1316
+ throwIfAmbientAborted(clientOptions.signal);
1121
1317
  continue;
1122
1318
  }
1123
1319
  throw normalizedError;
@@ -1129,11 +1325,13 @@ function createSessionFetcher(
1129
1325
  stalePoolError &&
1130
1326
  refreshAttempt < MAX_POLICY_PROXY_POOL_REFRESHES
1131
1327
  ) {
1328
+ throwIfAmbientAborted(clientOptions.signal);
1132
1329
  await invalidateProxyResolutionCacheAsync({
1133
1330
  proxyPolicy: clientOptions.proxyPolicy,
1134
1331
  upstream: clientOptions.upstream,
1135
1332
  affinityKey: clientOptions.affinityKey,
1136
1333
  });
1334
+ throwIfAmbientAborted(clientOptions.signal);
1137
1335
  continue;
1138
1336
  }
1139
1337
 
@@ -1169,6 +1367,7 @@ function createSessionFetcher(
1169
1367
  break;
1170
1368
  }
1171
1369
 
1370
+ throwIfAmbientAborted(clientOptions.signal);
1172
1371
  throw normalizeStealthTransportError(lastError);
1173
1372
  },
1174
1373
  cookies: cookieJar,
@@ -1392,16 +1591,31 @@ function createSessionFetcher(
1392
1591
  proxy: string,
1393
1592
  ): Promise<"source_ip_denied" | "edge_auth_rejected" | undefined> {
1394
1593
  try {
1395
- return await withClient(profileName, proxy, false, async (client) => {
1396
- await client.clearCookies();
1397
- const response = await client.fetch(PROXY_AUTH_DIAGNOSTIC_URL, {
1398
- method: "GET",
1399
- timeout: PROXY_AUTH_DIAGNOSTIC_TIMEOUT_MS,
1400
- });
1401
- const normalized = await normalizeResponse(response);
1402
- return classifyProxyAuthDiagnosticMessage(normalized.body);
1403
- });
1594
+ return await withClient(
1595
+ profileName,
1596
+ proxy,
1597
+ false,
1598
+ async (client) => {
1599
+ throwIfAmbientAborted(clientOptions.signal);
1600
+ await client.clearCookies();
1601
+ throwIfAmbientAborted(clientOptions.signal);
1602
+ const response = await client.fetch(PROXY_AUTH_DIAGNOSTIC_URL, {
1603
+ method: "GET",
1604
+ timeout: PROXY_AUTH_DIAGNOSTIC_TIMEOUT_MS,
1605
+ ...(clientOptions.signal ? { signal: clientOptions.signal } : {}),
1606
+ });
1607
+ const normalized = await normalizeResponseWithSignal(
1608
+ response,
1609
+ undefined,
1610
+ undefined,
1611
+ clientOptions.signal,
1612
+ );
1613
+ return classifyProxyAuthDiagnosticMessage(normalized.body);
1614
+ },
1615
+ clientOptions.signal,
1616
+ );
1404
1617
  } catch (error) {
1618
+ throwIfAmbientAborted(clientOptions.signal);
1405
1619
  const message =
1406
1620
  error instanceof Error
1407
1621
  ? [error.message, error.cause instanceof Error ? error.cause.message : ""]
@@ -667,6 +667,7 @@ function createProviderContext(
667
667
  upstream: proxyClientOptions.upstream,
668
668
  affinityKey: proxyClientOptions.affinityKey,
669
669
  telemetry: proxyTelemetry,
670
+ ...(signal ? { signal } : {}),
670
671
  };
671
672
  const { capabilityModules } = options;
672
673
  const logStealthCleanupError = (error: unknown) =>
@@ -891,6 +892,7 @@ function createAuthFlowContext(
891
892
  upstream: proxyClientOptions.upstream,
892
893
  affinityKey: proxyClientOptions.affinityKey,
893
894
  telemetry: proxyTelemetry,
895
+ ...(signal ? { signal } : {}),
894
896
  };
895
897
  const { capabilityModules } = options;
896
898
  const logStealthCleanupError = (error: unknown) =>