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

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,13 @@
1
1
  # @apifuse/provider-sdk Changelog
2
2
 
3
+ ## 2.2.0-beta.47
4
+
5
+ - Release candidate for main commit 0ff0c6c90153af3aab75978eb73040c9adaa2609.
6
+
7
+ ## 2.2.0-beta.46
8
+
9
+ - Release candidate for main commit 0340fbc51b4ad71689bb1ec61b9b818dedda4587.
10
+
3
11
  ## 2.2.0-beta.45
4
12
 
5
13
  - Release candidate for main commit 5d337d9fc1c77cd814ec7a4e1aa44ba0f8b2a5a9.
@@ -4312,6 +4312,7 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
4312
4312
  const operations = Object.entries(provider.operations);
4313
4313
  const missing: string[] = [];
4314
4314
  const vacuous: string[] = [];
4315
+ const evidenceFreeScenario: string[] = [];
4315
4316
  const placeholder: string[] = [];
4316
4317
  const unsupported: string[] = [];
4317
4318
  const generatedStarter: string[] = [];
@@ -4323,8 +4324,13 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
4323
4324
  missing.push(operationId);
4324
4325
  continue;
4325
4326
  }
4326
- if (hasCheck && !hasUnsupported && hasOnlyVacuousHealthCases(operation.healthCheck)) {
4327
- vacuous.push(operationId);
4327
+ if (hasCheck && !hasUnsupported) {
4328
+ const vacuousClass = vacuousHealthCaseClass(operation.healthCheck);
4329
+ if (vacuousClass === "empty-assertions") {
4330
+ vacuous.push(operationId);
4331
+ } else if (vacuousClass === "evidence-free-scenario") {
4332
+ evidenceFreeScenario.push(operationId);
4333
+ }
4328
4334
  }
4329
4335
  if (hasUnsupported) {
4330
4336
  const reason = operation.healthCheckUnsupported?.reason ?? "";
@@ -4351,14 +4357,36 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
4351
4357
  );
4352
4358
  }
4353
4359
 
4354
- if (vacuous.length > 0) {
4360
+ if (vacuous.length > 0 || evidenceFreeScenario.length > 0) {
4361
+ const remediationParts: string[] = [];
4362
+ if (vacuous.length > 0) {
4363
+ remediationParts.push(
4364
+ `healthCheck.assertions for ${vacuous.join(", ")} is empty — assert on status and response shape (e.g. throw or return {status:'degraded'} when the upstream contract breaks), or declare healthCheckUnsupported with a specific reason if the operation genuinely cannot be probed.`,
4365
+ );
4366
+ }
4367
+ if (evidenceFreeScenario.length > 0) {
4368
+ remediationParts.push(
4369
+ `healthCheck scenario for ${evidenceFreeScenario.join(", ")} asserts nothing beyond transport — add an assert step whose expression reads the probe response (a field under data, an extracted value, or a quantifier over response rows), not just status_2xx or a bare response-envelope check.`,
4370
+ );
4371
+ }
4372
+ const message =
4373
+ evidenceFreeScenario.length === 0
4374
+ ? "One or more operations have healthCheck cases with empty assertions."
4375
+ : vacuous.length === 0
4376
+ ? "One or more operations have healthCheck scenarios without response evidence."
4377
+ : "One or more operations have healthCheck cases with empty assertions or evidence-free scenarios.";
4355
4378
  return blocker(
4356
4379
  "health-coverage",
4357
4380
  "health",
4358
- "One or more operations have healthCheck cases with empty assertions.",
4359
- `healthCheck.assertions for ${vacuous.join(", ")} is empty — assert on status and response shape (e.g. throw or return {status:'degraded'} when the upstream contract breaks), or declare healthCheckUnsupported with a specific reason if the operation genuinely cannot be probed.`,
4381
+ message,
4382
+ remediationParts.join(" "),
4360
4383
  CATEGORY_MAX_POINTS.health,
4361
- vacuous.map((operationId) => `${operationId}: empty healthCheck.assertions`),
4384
+ [
4385
+ ...vacuous.map((operationId) => `${operationId}: empty healthCheck.assertions`),
4386
+ ...evidenceFreeScenario.map(
4387
+ (operationId) => `${operationId}: healthCheck scenario asserts nothing beyond transport`,
4388
+ ),
4389
+ ],
4362
4390
  );
4363
4391
  }
4364
4392
 
@@ -4413,14 +4441,216 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
4413
4441
  );
4414
4442
  }
4415
4443
 
4416
- function hasOnlyVacuousHealthCases(
4444
+ type VacuousHealthCaseClass = "empty-assertions" | "evidence-free-scenario";
4445
+
4446
+ /**
4447
+ * Classify an operation whose every health case is vacuous. Returns undefined
4448
+ * when at least one case carries real coverage: an imperative case with a
4449
+ * non-vacuous assertions function, or a declarative case whose scenario reads
4450
+ * the probe response. When all cases are vacuous, the class picks the blocker
4451
+ * wording — "empty-assertions" whenever any vacuous case is imperative (the
4452
+ * pre-scenario behavior, kept byte-identical), "evidence-free-scenario" when
4453
+ * the suite is scenario-only.
4454
+ */
4455
+ function vacuousHealthCaseClass(
4417
4456
  healthCheck: ProviderDefinition["operations"][string]["healthCheck"],
4418
- ): boolean {
4457
+ ): VacuousHealthCaseClass | undefined {
4419
4458
  const cases = healthCheck?.cases;
4420
4459
  if (!Array.isArray(cases) || cases.length === 0) {
4460
+ return "empty-assertions";
4461
+ }
4462
+ let sawImperativeCase = false;
4463
+ for (const healthCase of cases) {
4464
+ if (healthCase?.scenario !== undefined) {
4465
+ if (scenarioCarriesResponseEvidence(healthCase.scenario)) {
4466
+ return undefined;
4467
+ }
4468
+ continue;
4469
+ }
4470
+ sawImperativeCase = true;
4471
+ if (!isVacuousAssertionFunction(healthCase?.assertions)) {
4472
+ return undefined;
4473
+ }
4474
+ }
4475
+ return sawImperativeCase ? "empty-assertions" : "evidence-free-scenario";
4476
+ }
4477
+
4478
+ /**
4479
+ * A declarative case (`scenario`) carries real coverage when at least one
4480
+ * `assert` step reads the probe response beyond the transport envelope. The
4481
+ * SDK's own definitions drive this judgement:
4482
+ *
4483
+ * - The assert step is the scenario's only verdict-bearing construct
4484
+ * (`AssertResult.passed`). A guard can only attribute
4485
+ * degraded/expected_absence and stop the run (an escape hatch, not a health
4486
+ * verdict), and an operation step does not intrinsically fail on a non-2xx
4487
+ * response — that is what the explicit `status_2xx` predicate exists for. So
4488
+ * a scenario without a semantic assert step proves nothing beyond the
4489
+ * operation merely executing, which is the same emptiness the imperative
4490
+ * vacuity check flags.
4491
+ * - Leaves that cannot observe upstream content carry no evidence: predicates
4492
+ * whose operands read nothing produced by a step (pure literals,
4493
+ * attempt/credential metadata, and the `status_code`/`request_id`/`kind`
4494
+ * envelope fields of a step result — this is what makes the legacy transport
4495
+ * pin `status_2xx` on `["status_code"]` non-evidence), predicates comparing
4496
+ * a reference against an identical copy of itself (`equals(x, x)` holds for
4497
+ * any response), and positive-polarity presence/type pins (`exists`,
4498
+ * `type_is`, `not(not_exists(...))`, zero-bound `array_length_gte`) on the
4499
+ * bare `data` root — the declarative spellings of the legacy transport-only
4500
+ * assertion (`status === 200` + `typeof data === "object"`), satisfied by
4501
+ * any well-formed response body. Polarity matters: `not(exists(data))`
4502
+ * ("this probe must return no body") is falsified by ordinary responses, so
4503
+ * the carve-out only applies where the pin appears un-negated.
4504
+ * - Everything else counts: paths under `data`, extract-step results,
4505
+ * `duration_ms` (parity with imperative `ctx.durationMs` reads),
4506
+ * `status_2xx` applied to a body path (envelope-style upstreams answer HTTP
4507
+ * 200 and carry the real code in the body), and quantifiers, which iterate
4508
+ * response rows by construction.
4509
+ *
4510
+ * Fail-open contract (same as isVacuousAssertionFunction): the provider module
4511
+ * already validated this scenario against HealthScenarioSchema in its own SDK
4512
+ * at import time, so any shape this walk does not recognize (unknown step
4513
+ * kind, expression kind, operator, or reference namespace) indicates a newer
4514
+ * grammar, not an empty probe — treat it as evidence rather than flag it.
4515
+ * The inverse direction is accepted and documented: a determined author can
4516
+ * still fabricate a read that never fails (a decorative deep path inside a
4517
+ * disjunction, a degenerate bound). That effort is comparable to writing the
4518
+ * real one-line assertion — the same accepted limitation as the imperative
4519
+ * check's decorative-ctx-read — and the live `--smoke` probe remains the
4520
+ * runtime defense.
4521
+ */
4522
+ export function scenarioCarriesResponseEvidence(scenario: unknown): boolean {
4523
+ if (!isRecord(scenario) || !Array.isArray(scenario.steps)) {
4524
+ return true;
4525
+ }
4526
+ let sawUnknownStepKind = false;
4527
+ for (const step of scenario.steps) {
4528
+ if (!isRecord(step)) {
4529
+ sawUnknownStepKind = true;
4530
+ continue;
4531
+ }
4532
+ if (step.kind === "assert") {
4533
+ if (expressionReadsResponse(step.expression)) {
4534
+ return true;
4535
+ }
4536
+ continue;
4537
+ }
4538
+ if (step.kind !== "operation" && step.kind !== "extract" && step.kind !== "guard") {
4539
+ sawUnknownStepKind = true;
4540
+ }
4541
+ }
4542
+ return sawUnknownStepKind;
4543
+ }
4544
+
4545
+ /**
4546
+ * Predicate operators of the current scenario grammar. An operator outside
4547
+ * this set indicates a newer grammar and fails open (counts as evidence),
4548
+ * because a future operator may source the response through fields this walk
4549
+ * does not know about.
4550
+ */
4551
+ const KNOWN_SCENARIO_PREDICATE_OPERATORS: ReadonlySet<string> = new Set([
4552
+ "exists",
4553
+ "not_exists",
4554
+ "non_empty",
4555
+ "is_true",
4556
+ "equals",
4557
+ "not_equals",
4558
+ "contains",
4559
+ "matches",
4560
+ "number_gt",
4561
+ "number_gte",
4562
+ "number_lt",
4563
+ "number_lte",
4564
+ "array_length_eq",
4565
+ "array_length_gte",
4566
+ "array_length_lte",
4567
+ "status_2xx",
4568
+ "type_is",
4569
+ ]);
4570
+
4571
+ function expressionReadsResponse(expression: unknown, negationDepth = 0): boolean {
4572
+ if (!isRecord(expression)) {
4421
4573
  return true;
4422
4574
  }
4423
- return cases.every((healthCase) => isVacuousAssertionFunction(healthCase?.assertions));
4575
+ if (expression.kind === "all" || expression.kind === "any") {
4576
+ return Array.isArray(expression.clauses)
4577
+ ? expression.clauses.some((clause) => expressionReadsResponse(clause, negationDepth))
4578
+ : true;
4579
+ }
4580
+ if (expression.kind === "not") {
4581
+ return expressionReadsResponse(expression.clause, negationDepth + 1);
4582
+ }
4583
+ if (expression.kind === "quantifier") {
4584
+ return true;
4585
+ }
4586
+ if (expression.kind !== "predicate" || typeof expression.operator !== "string") {
4587
+ return true;
4588
+ }
4589
+ const operator = expression.operator;
4590
+ if (!KNOWN_SCENARIO_PREDICATE_OPERATORS.has(operator)) {
4591
+ return true;
4592
+ }
4593
+ const actualReads = operandReadsStepOutput(expression.actual);
4594
+ const expectedReads = operandReadsStepOutput(expression.expected);
4595
+ if (!actualReads && !expectedReads) {
4596
+ return false;
4597
+ }
4598
+ // A predicate comparing a reference against an identical copy of itself
4599
+ // (equals(x, x) and friends) holds or fails identically for every response,
4600
+ // so the reads discriminate nothing.
4601
+ if (
4602
+ actualReads &&
4603
+ expectedReads &&
4604
+ JSON.stringify(expression.actual) === JSON.stringify(expression.expected)
4605
+ ) {
4606
+ return false;
4607
+ }
4608
+ // Presence/type pins on the whole `data` root are satisfied by any
4609
+ // well-formed JSON body — but only in positive polarity. Under an odd
4610
+ // number of `not` wrappers the same pin means "this probe must return no
4611
+ // body / a different shape", which ordinary responses falsify, so it is a
4612
+ // real (if unusual) constraint and stays evidence.
4613
+ const positivePolarity = negationDepth % 2 === 0;
4614
+ const bareEnvelopePin = positivePolarity
4615
+ ? operator === "exists" ||
4616
+ operator === "type_is" ||
4617
+ (operator === "array_length_gte" && expression.expected === 0)
4618
+ : operator === "not_exists";
4619
+ if (bareEnvelopePin && !expectedReads && isBareResponseEnvelope(expression.actual)) {
4620
+ return false;
4621
+ }
4622
+ return true;
4623
+ }
4624
+
4625
+ function operandReadsStepOutput(operand: unknown): boolean {
4626
+ if (!isRecord(operand) || !isRecord(operand.ref)) {
4627
+ return false;
4628
+ }
4629
+ const reference = operand.ref;
4630
+ if (reference.namespace === "attempt" || reference.namespace === "credentials") {
4631
+ return false;
4632
+ }
4633
+ if (reference.namespace !== "steps") {
4634
+ return true;
4635
+ }
4636
+ if (!Array.isArray(reference.path) || reference.path.length === 0) {
4637
+ return true;
4638
+ }
4639
+ const head = reference.path[0];
4640
+ return head !== "status_code" && head !== "request_id" && head !== "kind";
4641
+ }
4642
+
4643
+ function isBareResponseEnvelope(operand: unknown): boolean {
4644
+ if (!isRecord(operand) || !isRecord(operand.ref)) {
4645
+ return false;
4646
+ }
4647
+ const reference = operand.ref;
4648
+ return (
4649
+ reference.namespace === "steps" &&
4650
+ Array.isArray(reference.path) &&
4651
+ reference.path.length === 1 &&
4652
+ reference.path[0] === "data"
4653
+ );
4424
4654
  }
4425
4655
 
4426
4656
  function isVacuousAssertionFunction(assertions: unknown): boolean {
@@ -1,3 +1,14 @@
1
- import type { ResolverContext } from "../types.js";
1
+ import type { ChallengeSolution, ProviderChallenge, ResolverContext } from "../types.js";
2
+ import type { TraceRecorder } from "./trace.js";
2
3
  export declare const RESOLVER_INSTRUMENTATION_METADATA: unique symbol;
4
+ /**
5
+ * Internal solve surface. The instrumentation layer threads its recorder as a
6
+ * third argument so vendor-level spans (`resolver.vendor.*`) attach to the
7
+ * active trace. Every wrapper that re-exposes `solve` must forward the extra
8
+ * arguments; a wrapper typed against the public two-argument `ResolverContext`
9
+ * silently drops the recorder and vendor spans vanish.
10
+ */
11
+ export type ResolverSolveWithRecorder = {
12
+ solve(challenge: ProviderChallenge, signal?: AbortSignal, traceRecorder?: TraceRecorder): Promise<ChallengeSolution>;
13
+ };
3
14
  export declare function createUnsupportedResolverClient(reason?: string): ResolverContext;
@@ -682,8 +682,8 @@ export function bindResolverSignal(resolver, defaultSignal) {
682
682
  if (!defaultSignal)
683
683
  return resolver;
684
684
  const boundResolver = {
685
- solve(challenge, signal = defaultSignal) {
686
- return resolver.solve(challenge, signal);
685
+ solve(challenge, signal = defaultSignal, traceRecorder) {
686
+ return resolver.solve(challenge, signal, traceRecorder);
687
687
  },
688
688
  };
689
689
  if (resolverCaches.has(resolver)) {
@@ -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)
@@ -24,7 +24,7 @@ import { getProviderBaseUrl } from "../runtime/provider.js";
24
24
  import { createOcrClientFromEnv } from "../runtime/ocr.js";
25
25
  import { PROXY_AUTH_IP_DENIED_CODE, PROXY_EDGE_AUTH_REJECTED_CODE, PROXY_POOL_EXHAUSTED_CODE, } from "../runtime/proxy-errors.js";
26
26
  import { PROVIDER_TELEMETRY_HEADER, ProxyTelemetryCollector, } from "../runtime/proxy-telemetry.js";
27
- import { createUnsupportedResolverClient } from "../runtime/resolver-shared.js";
27
+ import { createUnsupportedResolverClient, } from "../runtime/resolver-shared.js";
28
28
  import { assertRequiredSecretsPresent, listMissingRequiredSecrets, MISSING_SECRET_CODE, } from "../runtime/secrets.js";
29
29
  import { createProviderRuntimeStateFromEnv, createUnsupportedProviderRuntimeState, } from "../runtime/state.js";
30
30
  import { StealthCookieJar } from "../runtime/stealth-cookies.js";
@@ -291,8 +291,10 @@ function bindResolverSignalWithoutRuntime(resolver, defaultSignal) {
291
291
  if (!defaultSignal)
292
292
  return resolver;
293
293
  return {
294
- solve(challenge, signal = defaultSignal) {
295
- return resolver.solve(challenge, signal);
294
+ solve(challenge, signal = defaultSignal, traceRecorder) {
295
+ // Forward the instrumentation trace recorder so resolver.vendor.* spans
296
+ // survive this wrapper. See ResolverSolveWithRecorder in resolver-shared.ts.
297
+ return resolver.solve(challenge, signal, traceRecorder);
296
298
  },
297
299
  };
298
300
  }
@@ -409,6 +411,7 @@ function createProviderContext(provider, request, operationId, options, state =
409
411
  upstream: proxyClientOptions.upstream,
410
412
  affinityKey: proxyClientOptions.affinityKey,
411
413
  telemetry: proxyTelemetry,
414
+ ...(signal ? { signal } : {}),
412
415
  };
413
416
  const { capabilityModules } = options;
414
417
  const logStealthCleanupError = (error) => logProviderCleanupError(options.logger, provider, "operation", operationId, request.requestId, "stealth", error);
@@ -566,6 +569,7 @@ function createAuthFlowContext(provider, request, options, state, proxyTelemetry
566
569
  upstream: proxyClientOptions.upstream,
567
570
  affinityKey: proxyClientOptions.affinityKey,
568
571
  telemetry: proxyTelemetry,
572
+ ...(signal ? { signal } : {}),
569
573
  };
570
574
  const { capabilityModules } = options;
571
575
  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.47",
3
3
  "name": "@apifuse/provider-sdk",
4
4
  "private": false,
5
5
  "type": "module",
@@ -1,10 +1,26 @@
1
1
  import { ProviderError } from "../errors.js";
2
- import type { ResolverContext } from "../types.js";
2
+ import type { ChallengeSolution, ProviderChallenge, ResolverContext } from "../types.js";
3
+ import type { TraceRecorder } from "./trace.js";
3
4
 
4
5
  export const RESOLVER_INSTRUMENTATION_METADATA = Symbol.for(
5
6
  "@apifuse/provider-sdk/runtime/resolver-instrumentation-metadata",
6
7
  );
7
8
 
9
+ /**
10
+ * Internal solve surface. The instrumentation layer threads its recorder as a
11
+ * third argument so vendor-level spans (`resolver.vendor.*`) attach to the
12
+ * active trace. Every wrapper that re-exposes `solve` must forward the extra
13
+ * arguments; a wrapper typed against the public two-argument `ResolverContext`
14
+ * silently drops the recorder and vendor spans vanish.
15
+ */
16
+ export type ResolverSolveWithRecorder = {
17
+ solve(
18
+ challenge: ProviderChallenge,
19
+ signal?: AbortSignal,
20
+ traceRecorder?: TraceRecorder,
21
+ ): Promise<ChallengeSolution>;
22
+ };
23
+
8
24
  export function createUnsupportedResolverClient(reason?: string): ResolverContext {
9
25
  return {
10
26
  async solve() {
@@ -41,6 +41,7 @@ import {
41
41
  import {
42
42
  createUnsupportedResolverClient,
43
43
  RESOLVER_INSTRUMENTATION_METADATA,
44
+ type ResolverSolveWithRecorder,
44
45
  } from "./resolver-shared.js";
45
46
  import {
46
47
  APIFUSE__CDP_POOL__URL,
@@ -1022,8 +1023,12 @@ export function bindResolverSignal(
1022
1023
  ): ResolverContext {
1023
1024
  if (!defaultSignal) return resolver;
1024
1025
  const boundResolver: ResolverContext = {
1025
- solve(challenge, signal = defaultSignal) {
1026
- return resolver.solve(challenge, signal);
1026
+ solve(challenge, signal = defaultSignal, traceRecorder?: TraceRecorder) {
1027
+ return (resolver as Partial<ResolverSolveWithRecorder> & ResolverContext).solve(
1028
+ challenge,
1029
+ signal,
1030
+ traceRecorder,
1031
+ );
1027
1032
  },
1028
1033
  };
1029
1034
  if (resolverCaches.has(resolver)) {
@@ -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 : ""]
@@ -64,7 +64,10 @@ import {
64
64
  type ProxyTelemetryLogPayload,
65
65
  } from "../runtime/proxy-telemetry.js";
66
66
  import type * as ResolverRuntimeModule from "../runtime/resolver.js";
67
- import { createUnsupportedResolverClient } from "../runtime/resolver-shared.js";
67
+ import {
68
+ createUnsupportedResolverClient,
69
+ type ResolverSolveWithRecorder,
70
+ } from "../runtime/resolver-shared.js";
68
71
  import {
69
72
  assertRequiredSecretsPresent,
70
73
  listMissingRequiredSecrets,
@@ -77,7 +80,7 @@ import {
77
80
  import { StealthCookieJar } from "../runtime/stealth-cookies.js";
78
81
  import type * as StealthRuntimeModule from "../runtime/stealth.js";
79
82
  import { createSttClientFromEnv } from "../runtime/stt.js";
80
- import { createTraceContext } from "../runtime/trace.js";
83
+ import { createTraceContext, type TraceRecorder } from "../runtime/trace.js";
81
84
  import { resolveTraceConfigFromEnv } from "../runtime/trace-config.js";
82
85
  import { parseSchema } from "../schema.js";
83
86
  import {
@@ -493,8 +496,14 @@ function bindResolverSignalWithoutRuntime(
493
496
  ): ResolverContext {
494
497
  if (!defaultSignal) return resolver;
495
498
  return {
496
- solve(challenge, signal = defaultSignal) {
497
- return resolver.solve(challenge, signal);
499
+ solve(challenge, signal = defaultSignal, traceRecorder?: TraceRecorder) {
500
+ // Forward the instrumentation trace recorder so resolver.vendor.* spans
501
+ // survive this wrapper. See ResolverSolveWithRecorder in resolver-shared.ts.
502
+ return (resolver as Partial<ResolverSolveWithRecorder> & ResolverContext).solve(
503
+ challenge,
504
+ signal,
505
+ traceRecorder,
506
+ );
498
507
  },
499
508
  };
500
509
  }
@@ -667,6 +676,7 @@ function createProviderContext(
667
676
  upstream: proxyClientOptions.upstream,
668
677
  affinityKey: proxyClientOptions.affinityKey,
669
678
  telemetry: proxyTelemetry,
679
+ ...(signal ? { signal } : {}),
670
680
  };
671
681
  const { capabilityModules } = options;
672
682
  const logStealthCleanupError = (error: unknown) =>
@@ -891,6 +901,7 @@ function createAuthFlowContext(
891
901
  upstream: proxyClientOptions.upstream,
892
902
  affinityKey: proxyClientOptions.affinityKey,
893
903
  telemetry: proxyTelemetry,
904
+ ...(signal ? { signal } : {}),
894
905
  };
895
906
  const { capabilityModules } = options;
896
907
  const logStealthCleanupError = (error: unknown) =>