@agent-cards/checkout 0.15.2 → 0.16.0

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/dist/cdp.js CHANGED
@@ -306,7 +306,29 @@ function headerEntries(headers) {
306
306
  * undefined and the authorization goes out without one (a merchant, category
307
307
  * or place rule then refuses it as unknown, never the request itself).
308
308
  */
309
- async function pageOriginOf(readDocumentUrl, signal) {
309
+ function pageOriginFrom(documentUrl) {
310
+ if (typeof documentUrl !== 'string')
311
+ return undefined;
312
+ try {
313
+ const url = new URL(documentUrl);
314
+ if (url.username || url.password)
315
+ return undefined;
316
+ if (url.protocol === 'https:' || (url.protocol === 'http:' && url.hostname === 'localhost'))
317
+ return url.origin;
318
+ return undefined;
319
+ }
320
+ catch {
321
+ return undefined;
322
+ }
323
+ }
324
+ /**
325
+ * The top-level document's URL, read once with a one-second bound when a card
326
+ * request pauses: pageOriginFrom derives the merchant origin from it, and the
327
+ * retry rule (sameCheckoutRequest) compares it. Undefined when the page
328
+ * cannot be read, the attempt was stopped, or the read was late; the
329
+ * authorization then goes out without a page origin and no retry can bind.
330
+ */
331
+ async function documentUrlOf(readDocumentUrl, signal) {
310
332
  let timer;
311
333
  let abort;
312
334
  try {
@@ -320,14 +342,7 @@ async function pageOriginOf(readDocumentUrl, signal) {
320
342
  abort();
321
343
  }),
322
344
  ]);
323
- if (typeof documentUrl !== 'string')
324
- return undefined;
325
- const url = new URL(documentUrl);
326
- if (url.username || url.password)
327
- return undefined;
328
- if (url.protocol === 'https:' || (url.protocol === 'http:' && url.hostname === 'localhost'))
329
- return url.origin;
330
- return undefined;
345
+ return typeof documentUrl === 'string' ? documentUrl : undefined;
331
346
  }
332
347
  catch {
333
348
  return undefined;
@@ -391,13 +406,250 @@ function failureSummary(error) {
391
406
  return `${error.name}: ${error.code ?? `http_${error.status}`}`;
392
407
  return error instanceof Error ? error.name : 'CheckoutError';
393
408
  }
394
- /** Separate from explicit cancellation: the client can drain a late create ID. */
395
- function merchantAttempt(lifecycle) {
409
+ /**
410
+ * How long an approval waits, after the cardholder gives it, for the page to
411
+ * issue the request it will answer.
412
+ *
413
+ * This only matters when the page's own script abandoned its card request
414
+ * while the person was deciding (Braintree's client gives up after 60
415
+ * seconds, Square's after about 10) and did not ask again on its own. The
416
+ * approval is real: the device sent the card to the processor and a token
417
+ * came back, or encrypted it for the agent's browser. What is missing is a
418
+ * request to hand it to, and the application supplies one by clicking Pay
419
+ * again (the controller reads `ready_to_submit` with reason
420
+ * `awaiting_merchant_retry` while this wait runs). Two minutes is a first
421
+ * value, chosen rather than measured: long enough for an agent loop to read
422
+ * the state and act, short enough that a minted token never sits usable for
423
+ * the rest of the approval window. The approval window itself is the ceiling,
424
+ * whatever this is set to. Overridable per attach via `merchantRetryWaitMs`;
425
+ * the benchmark's retry gaps are what should replace it.
426
+ */
427
+ const MERCHANT_RETRY_WAIT_MS = 2 * 60_000;
428
+ function retryWaitMs(value) {
429
+ if (value === undefined)
430
+ return MERCHANT_RETRY_WAIT_MS;
431
+ if (!Number.isSafeInteger(value) || value <= 0 || value > 15 * 60_000) {
432
+ throw new Error('merchantRetryWaitMs must be an integer between 1 and 900000.');
433
+ }
434
+ return value;
435
+ }
436
+ /** The page never asked again inside the retry wait; see merchantAttempt.awaitTarget. */
437
+ class MerchantNeverRetried extends Error {
438
+ authorizationId;
439
+ constructor(authorizationId) {
440
+ super('the merchant page never asked again');
441
+ this.authorizationId = authorizationId;
442
+ this.name = 'MerchantNeverRetried';
443
+ }
444
+ }
445
+ /** A body field that names the purchase's amount or currency, wherever a processor puts it. */
446
+ const AMOUNT_KEY = /^(amount|amount_?cents|total|sum|currency|currency_?code)$/i;
447
+ /**
448
+ * The amount and currency a body names, as `path=value` pairs: Adyen's
449
+ * `amount.value` and `amount.currency`, Razorpay's and Nuvei's `amount`
450
+ * and `currency`, Paysafe's `amount` and `currencyCode`, Tranzila's `sum`.
451
+ * A pure tokenization (Braintree, Square, Shopify, a Stripe PaymentMethod)
452
+ * names none and yields an empty list.
453
+ */
454
+ function bodyAmounts(value, prefix, parentKey, out) {
455
+ if (Array.isArray(value)) {
456
+ for (const item of value)
457
+ bodyAmounts(item, `${prefix}[]`, parentKey, out);
458
+ return;
459
+ }
460
+ if (value === null || typeof value !== 'object') {
461
+ const key = prefix.split('.').pop() ?? '';
462
+ if (AMOUNT_KEY.test(key) || (key === 'value' && AMOUNT_KEY.test(parentKey)))
463
+ out.push(`${prefix}=${String(value)}`);
464
+ return;
465
+ }
466
+ const own = prefix.split('.').pop() ?? '';
467
+ for (const [key, child] of Object.entries(value))
468
+ bodyAmounts(child, prefix ? `${prefix}.${key}` : key, own, out);
469
+ }
470
+ /** The document a request came from, as compared between a request and its retry. */
471
+ function documentKey(url) {
472
+ if (typeof url !== 'string')
473
+ return undefined;
474
+ try {
475
+ const parsed = new URL(url);
476
+ parsed.hash = '';
477
+ return parsed.href;
478
+ }
479
+ catch {
480
+ return undefined;
481
+ }
482
+ }
483
+ /** Luhn, on a run of digits: the placeholder card the agent typed passes it, most other long numbers do not. */
484
+ function luhn(digits) {
485
+ let sum = 0;
486
+ for (let i = 0; i < digits.length; i++) {
487
+ let d = digits.charCodeAt(digits.length - 1 - i) - 48;
488
+ if (i % 2 === 1) {
489
+ d *= 2;
490
+ if (d > 9)
491
+ d -= 9;
492
+ }
493
+ sum += d;
494
+ }
495
+ return sum % 10 === 0;
496
+ }
497
+ /**
498
+ * The card-placeholder runs in a body: every 13 to 19 digit run that passes
499
+ * Luhn, sorted. The placeholder the agent typed (4242 4242 4242 4242, 4111
500
+ * 1111 1111 1111) passes; a millisecond timestamp or an order number fails
501
+ * nine times in ten, and when one passes it merely makes the match stricter.
502
+ * A client-side-encrypted body (Adyen) carries no digits at all and yields
503
+ * an empty list on both sides.
504
+ */
505
+ function cardRuns(body) {
506
+ return (body.match(/\d{13,19}/g) ?? []).filter(luhn).sort();
507
+ }
508
+ /** Every key path in a JSON value, arrays flattened so an element's order or count does not count. */
509
+ function jsonKeyPaths(value, prefix, out) {
510
+ if (Array.isArray(value)) {
511
+ for (const item of value)
512
+ jsonKeyPaths(item, `${prefix}[]`, out);
513
+ return;
514
+ }
515
+ if (value === null || typeof value !== 'object') {
516
+ if (prefix)
517
+ out.add(prefix);
518
+ return;
519
+ }
520
+ for (const [key, child] of Object.entries(value))
521
+ jsonKeyPaths(child, prefix ? `${prefix}.${key}` : key, out);
522
+ }
523
+ /**
524
+ * The shape of a body plus its card placeholder, as a string two bodies can
525
+ * be compared by. JSON: its sorted key paths and its card runs, so a body the
526
+ * client regenerated (a new session id, a new nonce, a fresh encryption of
527
+ * the same dummy card) still matches while a different operation, a saved
528
+ * card or another card does not. Form-encoded: its sorted keys and its card
529
+ * runs, so Stripe's per-attempt `time_on_page` and fraud ids do not break a
530
+ * match but a different field set does. Anything else: the body itself.
531
+ */
532
+ function bodyShape(body) {
533
+ const runs = cardRuns(body).join(',');
534
+ const trimmed = body.trimStart();
535
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
536
+ try {
537
+ const parsed = JSON.parse(body);
538
+ const paths = new Set();
539
+ jsonKeyPaths(parsed, '', paths);
540
+ const amounts = [];
541
+ bodyAmounts(parsed, '', '', amounts);
542
+ return { shape: `json:${[...paths].sort().join('|')};cards:${runs};amounts:${amounts.sort().join('|')}`, namesAmount: amounts.length > 0 };
543
+ }
544
+ catch { /* not JSON after all */ }
545
+ }
546
+ if (/^[^=&\s]+=[^&]*(&[^=&\s]+=[^&]*)*$/.test(body)) {
547
+ const params = new URLSearchParams(body);
548
+ const keys = [...new Set([...params.keys()])].sort();
549
+ const amounts = [...params.entries()].filter(([key]) => AMOUNT_KEY.test(key)).map(([key, value]) => `${key}=${value}`).sort();
550
+ return { shape: `form:${keys.join('|')};cards:${runs};amounts:${amounts.join('|')}`, namesAmount: amounts.length > 0 };
551
+ }
552
+ return { shape: `raw:${body}`, namesAmount: false };
553
+ }
554
+ /** The identity of a request from its parts; see CheckoutRequestIdentity. */
555
+ function requestIdentity(opts, url, method, body, documentUrl, pageAmount) {
556
+ const shaped = bodyShape(body);
557
+ return { url, method, documentUrl, pageAmount, body: shaped.shape,
558
+ amountEvidence: opts.amount != null || pageAmount !== undefined || shaped.namesAmount };
559
+ }
560
+ /**
561
+ * May this request be answered from the approval the other one raised?
562
+ *
563
+ * The plan this implements says the rule starts strict and loosens per
564
+ * processor with evidence, so it is strict: the same processor endpoint (the
565
+ * full URL, query included), the same method, the same top-level document,
566
+ * the same page total when the integrator reads one, and a body of the same
567
+ * shape carrying the same card placeholder and the same amount when the
568
+ * request names one. A request from another page, to another endpoint, for
569
+ * another card, for another amount or with another set of fields is a new
570
+ * question and is refused here, which leaves it to the ordinary "an approval
571
+ * is already outstanding" path.
572
+ *
573
+ * An amount has to be in evidence somewhere: the integrator's hint (what the
574
+ * cardholder was shown), the page total, or the request's own bytes. A
575
+ * tokenization request names no amount, so on a checkout with no hint and no
576
+ * page reader nothing could tell a retry for the approved cart from one for
577
+ * a changed cart, and the approval is not reused. With a page reader, a
578
+ * changed total refuses the retry; the hint is the integrator's statement of
579
+ * the purchase, and the approval was given for it.
580
+ */
581
+ function sameCheckoutRequest(first, retry) {
582
+ return first.amountEvidence && first.url === retry.url && first.method.toUpperCase() === retry.method.toUpperCase()
583
+ && first.documentUrl !== undefined && first.documentUrl === retry.documentUrl
584
+ && (first.pageAmount?.amount === retry.pageAmount?.amount && first.pageAmount?.currency === retry.pageAmount?.currency)
585
+ && first.body === retry.body;
586
+ }
587
+ /**
588
+ * Whether an approval may outlive the page's own request on this checkout.
589
+ *
590
+ * Yes for a plain tokenization or an encrypted-card request: the device's
591
+ * answer is a token or ciphertext, and any request for the same purchase can
592
+ * carry it. No for a hosted form (a navigation: there is no request to
593
+ * answer once the page has moved on), a prepared checkout (single use, bound
594
+ * to one native request by design), a native Stripe Checkout step, and a
595
+ * Mercado Pago request whose claim is bound to one document read. Unknown
596
+ * modes (a duck-typed vault without checkoutModeOf) are taken as `token`.
597
+ */
598
+ function survivesAbandonment(opts, url, method, navigation, bound) {
599
+ if (navigation || bound)
600
+ return false;
601
+ const mode = typeof opts.vault?.checkoutModeOf === 'function' ? opts.vault.checkoutModeOf(url, method) : 'token';
602
+ return mode === 'token' || mode === 'cse';
603
+ }
604
+ /**
605
+ * One card request's attempt at an approval, and the browser request that
606
+ * currently carries it. Separate from explicit cancellation: the client can
607
+ * drain a late create ID.
608
+ *
609
+ * The request and the approval have different lives. The page's own script
610
+ * can abandon its request (Braintree's client gives up after 60 seconds,
611
+ * Square's after about 10) while the cardholder is still deciding on their
612
+ * phone; nothing about the checkout changed, the page asked and stopped
613
+ * listening. lose() records that: the approval keeps polling, the page's next
614
+ * request for the same purchase binds through bind() and is answered from the
615
+ * same authorization, and awaitTarget() is how the approval finds the request
616
+ * to answer, waiting for a retry when none is live. stop() is for the page
617
+ * itself going away (navigation of a prepared document, frame removal, close,
618
+ * crash), a request lost once delivery began, or an attempt that cannot
619
+ * survive abandonment at all: the approval cannot be reopened and the client
620
+ * cancels it.
621
+ */
622
+ function merchantAttempt(lifecycle, survives) {
396
623
  const controller = new AbortController();
397
624
  let reason = 'merchant_request_aborted';
625
+ let target = null;
626
+ let lost = false;
627
+ let rebound = false;
628
+ let delivering = false;
629
+ let bound = null;
398
630
  const error = () => new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, reason);
399
- return {
631
+ const attempt = {
400
632
  signal: controller.signal,
633
+ /** The browser request that currently carries this attempt; null while the page has none. */
634
+ get target() { return target; },
635
+ /** The page abandoned its request and has not asked again yet. */
636
+ get lost() { return lost; },
637
+ bind(next) { target = next; rebound = lost; lost = false; bound?.(); },
638
+ /** Delivery to the current target has begun; a loss from here on is an unknown outcome, never a retry. */
639
+ beginDelivery() { delivering = true; },
640
+ /** The page abandoned the current request. True when the approval survives it, false when the attempt stopped. */
641
+ lose() {
642
+ if (controller.signal.aborted)
643
+ return false;
644
+ if (!survives || delivering) {
645
+ attempt.stop();
646
+ return false;
647
+ }
648
+ target = null;
649
+ lost = true;
650
+ lifecycle.merchantRequestLost();
651
+ return true;
652
+ },
401
653
  stop(cause = 'merchant_request_aborted') {
402
654
  if (controller.signal.aborted)
403
655
  return;
@@ -410,7 +662,82 @@ function merchantAttempt(lifecycle) {
410
662
  },
411
663
  assertLive() { if (controller.signal.aborted)
412
664
  throw error(); },
665
+ /**
666
+ * The request to answer: the live one at once, or the page's retry,
667
+ * waited for up to `waitMs`. Resolves `rebound` when the answer goes to
668
+ * a request other than the one that raised the approval, so the caller
669
+ * can re-run request attribution on it. Rejects with MerchantNeverRetried
670
+ * when the wait ends with no request, and with the attempt's own error
671
+ * when the page goes away meanwhile.
672
+ */
673
+ async awaitTarget(waitMs, stopSignals, onWaiting) {
674
+ attempt.assertLive();
675
+ if (target && !lost)
676
+ return { target, rebound };
677
+ onWaiting();
678
+ const signals = [controller.signal, ...stopSignals];
679
+ let timer;
680
+ let onAbort = () => { };
681
+ try {
682
+ await new Promise((resolve, reject) => {
683
+ bound = resolve;
684
+ onAbort = () => { try {
685
+ attempt.assertLive();
686
+ reject(new Error('checkout cancelled locally before the merchant asked again'));
687
+ }
688
+ catch (stopped) {
689
+ reject(stopped);
690
+ } };
691
+ for (const signal of signals)
692
+ signal.addEventListener('abort', onAbort, { once: true });
693
+ if (signals.some((signal) => signal.aborted))
694
+ onAbort();
695
+ timer = setTimeout(() => reject(new MerchantNeverRetried(lifecycle.getState().authorizationId)), waitMs);
696
+ });
697
+ }
698
+ finally {
699
+ bound = null;
700
+ clearTimeout(timer);
701
+ for (const signal of signals)
702
+ signal.removeEventListener('abort', onAbort);
703
+ }
704
+ attempt.assertLive();
705
+ if (!target)
706
+ throw new MerchantNeverRetried(lifecycle.getState().authorizationId);
707
+ return { target, rebound };
708
+ },
413
709
  };
710
+ return attempt;
711
+ }
712
+ /**
713
+ * The retry wait, capped by what is left of the approval window: a token
714
+ * must never sit usable past the moment the row itself would have expired.
715
+ */
716
+ function boundedRetryWait(waitMs, approvalStartedAt, timeoutMs) {
717
+ const windowEnd = approvalStartedAt + (timeoutMs ?? 15 * 60_000);
718
+ return Math.max(0, Math.min(waitMs, windowEnd - Date.now()));
719
+ }
720
+ /**
721
+ * Retire an approval the page never asked for again, and tell the lifecycle
722
+ * what became of it. A confirmed retirement ends as `declined` with reason
723
+ * `merchant_never_retried` (the cardholder's screen says the merchant did
724
+ * not finish and nothing was charged, and the next card request is a new
725
+ * question); anything else is an unknown outcome the application must
726
+ * reconcile, which holds the attachment. A vault without cancelAuthorization
727
+ * (a duck-typed client) cannot retire anything and lands there too.
728
+ */
729
+ async function retireUnusedApproval(opts, lifecycle, authorizationId) {
730
+ const cancel = typeof opts.vault?.cancelAuthorization === 'function' && authorizationId
731
+ ? opts.vault.cancelAuthorization(authorizationId, 'merchant_never_retried') : Promise.reject(new Error('no authorization to retire'));
732
+ try {
733
+ await cancel;
734
+ lifecycle.merchantNeverRetried(authorizationId);
735
+ opts.onEvent?.({ type: 'failed', detail: 'merchant_never_retried' });
736
+ }
737
+ catch {
738
+ lifecycle.failed(new PaymentOutcomeUnknownError(authorizationId, 'authorization_cancel_unconfirmed'), true);
739
+ opts.onEvent?.({ type: 'failed', detail: 'PaymentOutcomeUnknownError' });
740
+ }
414
741
  }
415
742
  /**
416
743
  * Take over card tokenization for a page.
@@ -507,6 +834,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
507
834
  let quietUntil = 0;
508
835
  // One outstanding approval at a time; see the note above isApprovalOutcome.
509
836
  let awaitingApproval = false;
837
+ // How long an approval waits for the page to ask again; see MERCHANT_RETRY_WAIT_MS.
838
+ const retryWait = retryWaitMs(opts.merchantRetryWaitMs);
510
839
  let activeRequest = null;
511
840
  const sourceOwner = (source) => workerOwners.get(source.sessionId) ?? source.sessionId;
512
841
  const retireSource = (source) => {
@@ -716,6 +1045,12 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
716
1045
  }
717
1046
  if (preparationGate.isEngaged())
718
1047
  activeRequest?.attempt.stop();
1048
+ // The page that abandoned its request has moved to another document:
1049
+ // its approval cannot be answered there and is cancelled. A reload of
1050
+ // the same document is not that; its re-issued request is the retry.
1051
+ if (method === 'Page.frameNavigated' && activeRequest?.attempt.lost
1052
+ && documentKey(params.frame?.url) !== activeRequest.identity.documentUrl)
1053
+ activeRequest.attempt.stop();
719
1054
  return;
720
1055
  }
721
1056
  if ((method === 'Inspector.detached' && sessionId === pageSessionId)
@@ -761,9 +1096,26 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
761
1096
  return;
762
1097
  }
763
1098
  if (method === 'Network.loadingFailed') {
764
- if (activeRequest && activeRequest.networkId === params.requestId
765
- && activeRequest.sourceSessionId === sessionId)
766
- activeRequest.attempt.stop();
1099
+ if (activeRequest && activeRequest.networkId === params.requestId && activeRequest.sourceSessionId === sessionId) {
1100
+ const dead = { requestId: activeRequest.requestId, sessionId: activeRequest.sessionId, request: activeRequest.request };
1101
+ // A request a dedicated worker sent keeps today's behaviour: its loss
1102
+ // stops the attempt. A worker's retry pauses on the owning frame and
1103
+ // is attributed only through the worker's own Network events, and
1104
+ // binding an approval across that attribution is not something this
1105
+ // change claims to get right; the observed failure is page script.
1106
+ const fromWorker = sessionId !== activeRequest.sessionId;
1107
+ if (fromWorker ? (activeRequest.attempt.stop(), false) : activeRequest.attempt.lose()) {
1108
+ // The page's own script gave up on its request; the cardholder's
1109
+ // approval did not (see merchantAttempt). Release the dead
1110
+ // interception and forget the request's identity, so no later event
1111
+ // on it can pass for the retry's.
1112
+ activeRequest.networkId = '';
1113
+ activeRequest.frameId = undefined;
1114
+ activeRequest.sourceSessionId = undefined;
1115
+ opts.onEvent?.({ type: 'merchant_request_lost', detail: { authorizationId: lifecycle.getState().authorizationId } });
1116
+ cdp.send('Fetch.failRequest', { requestId: dead.requestId, errorReason: 'Aborted' }, dead.sessionId).catch(() => { });
1117
+ }
1118
+ }
767
1119
  const key = requestKey(sessionId, params.requestId);
768
1120
  retireSource(requestSources.get(key) ?? { sessionId, networkId: params.requestId });
769
1121
  requestSources.delete(key);
@@ -940,13 +1292,37 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
940
1292
  }
941
1293
  let preparation;
942
1294
  try {
943
- preparation = stripeStep ? undefined : preparationGate.claim(request.url, pausedBody(request));
1295
+ preparation = stripeStep ? undefined : preparationGate.claim(request.url, pausedBody(request), request.headers);
944
1296
  }
945
1297
  catch (error) {
946
1298
  opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
947
1299
  await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
948
1300
  return;
949
1301
  }
1302
+ // The page asking again for the purchase an outstanding approval is for:
1303
+ // its own request timed out while the cardholder decides (see
1304
+ // merchantAttempt). Bind it to that approval instead of refusing it or
1305
+ // raising a second prompt. A request that is not the same purchase falls
1306
+ // through to the ordinary refusal below.
1307
+ if (awaitingApproval && activeRequest?.attempt.lost && !terminal && !setupStop.signal.aborted && !lifecycle.isCancelled()
1308
+ && typeof networkId === 'string' && networkId) {
1309
+ const retryBody = pausedBody(request);
1310
+ const retry = retryBody === null ? null : requestIdentity(opts, request.url, request.method, retryBody, documentKey(await documentUrlOf(readDocumentUrl, lifecycle.abort.signal)), await pageAmountOf(opts));
1311
+ if (retry && activeRequest?.attempt.lost && sameCheckoutRequest(activeRequest.identity, retry)) {
1312
+ if (preparation)
1313
+ preparationGate.retireUnboundClaim();
1314
+ activeRequest.requestId = requestId;
1315
+ activeRequest.networkId = networkId;
1316
+ activeRequest.sessionId = sessionId;
1317
+ activeRequest.frameId = frameId;
1318
+ activeRequest.request = request;
1319
+ activeRequest.sourceSessionId = undefined;
1320
+ opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url), ...(resourceType ? { resourceType } : {}),
1321
+ authorizationId: lifecycle.getState().authorizationId, retry: true } });
1322
+ activeRequest.attempt.bind({ requestId, sessionId, request });
1323
+ return;
1324
+ }
1325
+ }
950
1326
  // Same stop condition as the Playwright adapter: once a failure proves
951
1327
  // retrying is pointless, fail the request without calling the API again.
952
1328
  if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
@@ -961,8 +1337,11 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
961
1337
  // requests can never both clear the check above and raise two prompts for
962
1338
  // one checkout.
963
1339
  awaitingApproval = true;
964
- const attempt = merchantAttempt(lifecycle);
1340
+ const attempt = merchantAttempt(lifecycle, survivesAbandonment(opts, request.url, request.method, resourceType === 'Document', !!preparation || !!stripeStep || mercadoCheckout.requiresDocument(request.url, request.method)));
965
1341
  let handoffStarted = false;
1342
+ // The approval the device gave, and whether it reached a browser request.
1343
+ let approvedId = null;
1344
+ let deliveryBegun = false;
966
1345
  try {
967
1346
  const body = pausedBody(request);
968
1347
  if (body === null) {
@@ -983,17 +1362,26 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
983
1362
  }
984
1363
  opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url), ...(resourceType ? { resourceType } : {}) } });
985
1364
  lifecycle.begin();
1365
+ const approvalStartedAt = Date.now();
986
1366
  if (typeof networkId !== 'string' || !networkId) {
987
1367
  attempt.stop();
988
1368
  attempt.assertLive();
989
1369
  }
990
- activeRequest = { attempt, networkId, sessionId, frameId, request };
1370
+ attempt.bind({ requestId, sessionId, request });
1371
+ activeRequest = { attempt, requestId, networkId, sessionId, frameId, request,
1372
+ identity: requestIdentity(opts, request.url, request.method, body, undefined, undefined) };
991
1373
  await awaitRequestSource(activeRequest);
992
1374
  if (preparation)
993
1375
  await preparationGate.assertDocument();
994
- const pageOrigin = await pageOriginOf(readDocumentUrl, attempt.signal);
1376
+ // One bounded read of the document serves both the merchant origin and
1377
+ // the identity a retry has to repeat (see sameCheckoutRequest).
1378
+ const documentUrl = await documentUrlOf(readDocumentUrl, attempt.signal);
1379
+ const pageOrigin = pageOriginFrom(documentUrl);
995
1380
  const pageAmount = await pageAmountOf(opts);
996
1381
  attempt.assertLive();
1382
+ activeRequest.identity.documentUrl = documentKey(documentUrl);
1383
+ activeRequest.identity.pageAmount = pageAmount;
1384
+ activeRequest.identity.amountEvidence ||= pageAmount !== undefined;
997
1385
  if (stripeStep)
998
1386
  stripeCheckout.assertClaim(stripeStep);
999
1387
  const merchantOrigin = preparation?.merchantOrigin ?? (opts.executionMode === 'user_approval'
@@ -1025,6 +1413,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1025
1413
  ? { mercado_checkout: mercadoCheckout.claimToken(request.url, request.method, await readDocumentUrl()) } : {}) },
1026
1414
  });
1027
1415
  attempt.assertLive();
1416
+ approvedId = replay.authorizationId;
1028
1417
  if (lifecycle.isCancelled())
1029
1418
  throw new Error('checkout cancelled locally after approval');
1030
1419
  try {
@@ -1034,7 +1423,23 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1034
1423
  throw new PaymentOutcomeUnknownError(replay.authorizationId, 'mercado_checkout_metadata_invalid');
1035
1424
  }
1036
1425
  attempt.assertLive();
1037
- lifecycle.prepareHandoff(replay, request.url);
1426
+ // The request to answer: the one that raised the approval while it is
1427
+ // still paused, or the page's retry when the page gave up on the first
1428
+ // (waited for, bounded; see merchantAttempt.awaitTarget). A retry is
1429
+ // attributed to its frame or worker exactly as the first request was.
1430
+ const delivery = await attempt.awaitTarget(boundedRetryWait(retryWait, approvalStartedAt, opts.timeoutMs), [lifecycle.abort.signal, setupStop.signal], () => {
1431
+ lifecycle.awaitingMerchantRetry(replay.authorizationId);
1432
+ opts.onEvent?.({ type: 'approval_awaiting_merchant_retry', detail: { authorizationId: replay.authorizationId } });
1433
+ });
1434
+ if (delivery.rebound) {
1435
+ await awaitRequestSource(activeRequest);
1436
+ attempt.assertLive();
1437
+ }
1438
+ const { requestId: deliverTo, sessionId: deliverOn, request: deliverRequest } = delivery.target;
1439
+ const deliverBody = delivery.rebound ? pausedBody(deliverRequest) ?? body : body;
1440
+ deliveryBegun = true;
1441
+ attempt.beginDelivery();
1442
+ lifecycle.prepareHandoff(replay, deliverRequest.url);
1038
1443
  handoffStarted = replay.mode !== 'cse';
1039
1444
  if (replay.mode === 'hosted_form') {
1040
1445
  // The device submitted the processor's own form; the processor
@@ -1046,13 +1451,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1046
1451
  // A navigation response is never CORS-checked, so the CORS wrap is
1047
1452
  // inert here; it is applied so every fulfill goes through one path.
1048
1453
  await cdp.send('Fetch.fulfillRequest', {
1049
- requestId,
1454
+ requestId: deliverTo,
1050
1455
  responseCode: page.status,
1051
- responseHeaders: headerEntries(withCorsHeaders(page.headers, corsHeadersFor(request.url, request.headers))),
1456
+ responseHeaders: headerEntries(withCorsHeaders(page.headers, corsHeadersFor(deliverRequest.url, deliverRequest.headers))),
1052
1457
  body: Buffer.from(page.body).toString('base64'),
1053
- }, sessionId);
1458
+ }, deliverOn);
1054
1459
  attempt.assertLive();
1055
- lastSubmitted = { url: request.url, body, at: Date.now() };
1460
+ lastSubmitted = { url: deliverRequest.url, body: deliverBody, at: Date.now() };
1056
1461
  // Named for what it is: a device-attested submission with no
1057
1462
  // processor evidence, never an `authorized` event.
1058
1463
  opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
@@ -1063,12 +1468,12 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1063
1468
  // and cookies, and only the four ciphertext fields swapped in. Only
1064
1469
  // postData rides on the command: no header override, ever (see
1065
1470
  // cseBody for why a recomputed Content-Length is refused by Chromium).
1066
- const postData = Buffer.from(cseBody(body, replay)).toString('base64');
1471
+ const postData = Buffer.from(cseBody(deliverBody, replay)).toString('base64');
1067
1472
  handoffStarted = true;
1068
1473
  await cdp.send('Fetch.continueRequest', {
1069
- requestId,
1474
+ requestId: deliverTo,
1070
1475
  postData,
1071
- }, sessionId);
1476
+ }, deliverOn);
1072
1477
  attempt.assertLive();
1073
1478
  opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
1074
1479
  }
@@ -1079,13 +1484,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1079
1484
  // decision rides on the event, so a fulfill the page could not read
1080
1485
  // (no usable Origin on a cross-origin request) is visible in telemetry
1081
1486
  // rather than only as the page's own "connection error".
1082
- const cors = corsDecision(request.url, request.headers);
1487
+ const cors = corsDecision(deliverRequest.url, deliverRequest.headers);
1083
1488
  await cdp.send('Fetch.fulfillRequest', {
1084
- requestId,
1489
+ requestId: deliverTo,
1085
1490
  responseCode: replay.status,
1086
1491
  responseHeaders: headerEntries(withCorsHeaders(replay.headers, cors.headers)),
1087
1492
  body: Buffer.from(replay.body).toString('base64'),
1088
- }, sessionId);
1493
+ }, deliverOn);
1089
1494
  attempt.assertLive();
1090
1495
  opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
1091
1496
  }
@@ -1093,6 +1498,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1093
1498
  }
1094
1499
  catch (err) {
1095
1500
  // Ignore the failure event caused by our own safe decline/error abort.
1501
+ const dead = attempt.target ?? { requestId, sessionId };
1096
1502
  if (activeRequest?.attempt === attempt)
1097
1503
  activeRequest = null;
1098
1504
  // Vault clients may normalize a merchantSignal abort. Preserve the
@@ -1103,15 +1509,25 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1103
1509
  catch (stopped) {
1104
1510
  err = stopped;
1105
1511
  }
1106
- // stop() already published the terminal state before aborting the Vault.
1107
- if (!attempt.signal.aborted)
1108
- lifecycle.failed(err, handoffStarted);
1109
- if (isTerminal(err))
1110
- terminal = err;
1111
- else if (isApprovalOutcome(err))
1512
+ if (err instanceof MerchantNeverRetried || (attempt.signal.aborted && approvedId && !deliveryBegun)) {
1513
+ // The device approved and no request ever carried the answer: the
1514
+ // page never asked again, or went away while the approval waited.
1515
+ // Retire it (see retireUnusedApproval) and quiet the page's next
1516
+ // request as after any other answered approval.
1517
+ await retireUnusedApproval(opts, lifecycle, approvedId);
1112
1518
  quietUntil = Date.now() + cooldownMs;
1113
- opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
1114
- await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
1519
+ }
1520
+ else {
1521
+ // stop() already published the terminal state before aborting the Vault.
1522
+ if (!attempt.signal.aborted)
1523
+ lifecycle.failed(err, handoffStarted);
1524
+ if (isTerminal(err))
1525
+ terminal = err;
1526
+ else if (isApprovalOutcome(err))
1527
+ quietUntil = Date.now() + cooldownMs;
1528
+ opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
1529
+ }
1530
+ await cdp.send('Fetch.failRequest', { requestId: dead.requestId, errorReason: 'Aborted' }, dead.sessionId).catch(() => { });
1115
1531
  }
1116
1532
  finally {
1117
1533
  if (activeRequest?.attempt === attempt)
@@ -1216,10 +1632,18 @@ export async function attachToPlaywright(page, opts) {
1216
1632
  let quietUntil = 0;
1217
1633
  // One outstanding approval at a time; see the note above isApprovalOutcome.
1218
1634
  let awaitingApproval = false;
1635
+ // How long an approval waits for the page to ask again; see MERCHANT_RETRY_WAIT_MS.
1636
+ const retryWait = retryWaitMs(opts.merchantRetryWaitMs);
1219
1637
  let activeRequest = null;
1220
1638
  page.on?.('requestfailed', (request) => {
1221
- if (activeRequest && activeRequest.request === request)
1222
- activeRequest.attempt.stop();
1639
+ if (activeRequest && activeRequest.request === request && activeRequest.attempt.lose()) {
1640
+ // The page's own script gave up on its request; the cardholder's
1641
+ // approval did not (see merchantAttempt). Forget the request's identity
1642
+ // so no later event on it can pass for the retry's.
1643
+ activeRequest.request = null;
1644
+ activeRequest.frames = [];
1645
+ opts.onEvent?.({ type: 'merchant_request_lost', detail: { authorizationId: lifecycle.getState().authorizationId } });
1646
+ }
1223
1647
  });
1224
1648
  page.on?.('close', () => { if (!attachmentReady)
1225
1649
  setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); stripeCheckout.invalidate(); mercadoCheckout.invalidate(); activeRequest?.attempt.stop(); });
@@ -1235,6 +1659,18 @@ export async function attachToPlaywright(page, opts) {
1235
1659
  }
1236
1660
  if (preparationGate.isEngaged())
1237
1661
  activeRequest?.attempt.stop();
1662
+ // The page that abandoned its request has moved to another document:
1663
+ // its approval cannot be answered there and is cancelled. A reload of
1664
+ // the same document is not that; its re-issued request is the retry.
1665
+ let url;
1666
+ try {
1667
+ url = typeof frame.url === 'function' ? frame.url() : undefined;
1668
+ }
1669
+ catch {
1670
+ url = undefined;
1671
+ }
1672
+ if (activeRequest?.attempt.lost && documentKey(url) !== activeRequest.identity.documentUrl)
1673
+ activeRequest.attempt.stop();
1238
1674
  }
1239
1675
  });
1240
1676
  page.on?.('framedetached', (frame) => {
@@ -1347,12 +1783,33 @@ export async function attachToPlaywright(page, opts) {
1347
1783
  }
1348
1784
  let preparation;
1349
1785
  try {
1350
- preparation = stripeStep ? undefined : preparationGate.claim(request.url(), request.postData() ?? '');
1786
+ preparation = stripeStep ? undefined : preparationGate.claim(request.url(), request.postData() ?? '', request.headers());
1351
1787
  }
1352
1788
  catch (error) {
1353
1789
  opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
1354
1790
  return route.abort('aborted');
1355
1791
  }
1792
+ // The page asking again for the purchase an outstanding approval is
1793
+ // for, after its own request timed out: bind it to that approval (see
1794
+ // merchantAttempt and the same check in attachToCdp).
1795
+ if (awaitingApproval && activeRequest?.attempt.lost && !terminal && !setupStop.signal.aborted && !lifecycle.isCancelled()) {
1796
+ const retry = requestIdentity(opts, request.url(), request.method(), request.postData() ?? '', documentKey(await documentUrlOf(readDocumentUrl, lifecycle.abort.signal)), await pageAmountOf(opts));
1797
+ if (activeRequest?.attempt.lost && sameCheckoutRequest(activeRequest.identity, retry)) {
1798
+ if (preparation)
1799
+ preparationGate.retireUnboundClaim();
1800
+ const retryFrames = [];
1801
+ try {
1802
+ for (let frame = request.frame?.(); frame; frame = frame.parentFrame?.())
1803
+ retryFrames.push(frame);
1804
+ }
1805
+ catch { /* covered by requestfailed */ }
1806
+ activeRequest.request = request;
1807
+ activeRequest.frames = retryFrames;
1808
+ opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()), authorizationId: lifecycle.getState().authorizationId, retry: true } });
1809
+ activeRequest.attempt.bind({ route, request, frames: retryFrames });
1810
+ return;
1811
+ }
1812
+ }
1356
1813
  // Fail closed and stay quiet: no card may reach the PSP, but neither may
1357
1814
  // the page's retry loop turn into a stream of doomed API calls. Every
1358
1815
  // abort in this adapter is 'aborted' (ERR_ABORTED), the same code the
@@ -1367,19 +1824,34 @@ export async function attachToPlaywright(page, opts) {
1367
1824
  }
1368
1825
  // Reserved before anything that could yield, matching attachToCdp.
1369
1826
  awaitingApproval = true;
1370
- const attempt = merchantAttempt(lifecycle);
1827
+ let navigation = false;
1828
+ try {
1829
+ navigation = typeof request.resourceType === 'function' && request.resourceType() === 'document';
1830
+ }
1831
+ catch {
1832
+ navigation = false;
1833
+ }
1834
+ const attempt = merchantAttempt(lifecycle, survivesAbandonment(opts, request.url(), request.method(), navigation, !!preparation || !!stripeStep || mercadoCheckout.requiresDocument(request.url(), request.method())));
1371
1835
  const frames = [];
1372
1836
  try {
1373
1837
  for (let frame = request.frame?.(); frame; frame = frame.parentFrame?.())
1374
1838
  frames.push(frame);
1375
1839
  }
1376
1840
  catch { /* requestfailed/page close still cover unavailable frame metadata */ }
1841
+ // Judged on the request that currently carries the attempt: after a
1842
+ // retry binds, the first request's failure is history, not a loss.
1377
1843
  const assertRequestLive = () => {
1378
- if (request.failure?.() || page.isClosed?.() || frames.some(frame => frame.isDetached?.()))
1844
+ const current = attempt.target;
1845
+ if (current && (current.request.failure?.() || current.frames.some((frame) => frame.isDetached?.())))
1846
+ attempt.lose();
1847
+ if (page.isClosed?.())
1379
1848
  attempt.stop();
1380
1849
  attempt.assertLive();
1381
1850
  };
1382
1851
  let handoffStarted = false;
1852
+ // The approval the device gave, and whether it reached a browser request.
1853
+ let approvedId = null;
1854
+ let deliveryBegun = false;
1383
1855
  try {
1384
1856
  const body = request.postData() ?? '';
1385
1857
  if (isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
@@ -1388,12 +1860,20 @@ export async function attachToPlaywright(page, opts) {
1388
1860
  }
1389
1861
  opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()) } });
1390
1862
  lifecycle.begin();
1391
- activeRequest = { request, frames, attempt };
1863
+ const approvalStartedAt = Date.now();
1864
+ attempt.bind({ route, request, frames });
1865
+ activeRequest = { request, frames, attempt,
1866
+ identity: requestIdentity(opts, request.url(), request.method(), body, undefined, undefined) };
1392
1867
  if (preparation)
1393
1868
  await preparationGate.assertDocument();
1394
- const pageOrigin = await pageOriginOf(readDocumentUrl, attempt.signal);
1869
+ // One bounded read serves the merchant origin and the retry identity; see attachToCdp.
1870
+ const documentUrl = await documentUrlOf(readDocumentUrl, attempt.signal);
1871
+ const pageOrigin = pageOriginFrom(documentUrl);
1395
1872
  const pageAmount = await pageAmountOf(opts);
1396
1873
  assertRequestLive();
1874
+ activeRequest.identity.documentUrl = documentKey(documentUrl);
1875
+ activeRequest.identity.pageAmount = pageAmount;
1876
+ activeRequest.identity.amountEvidence ||= pageAmount !== undefined;
1397
1877
  if (stripeStep)
1398
1878
  stripeCheckout.assertClaim(stripeStep);
1399
1879
  const merchantOrigin = preparation?.merchantOrigin ?? (opts.executionMode === 'user_approval'
@@ -1425,6 +1905,7 @@ export async function attachToPlaywright(page, opts) {
1425
1905
  ? { mercado_checkout: mercadoCheckout.claimToken(request.url(), request.method(), await readDocumentUrl()) } : {}) },
1426
1906
  });
1427
1907
  assertRequestLive();
1908
+ approvedId = replay.authorizationId;
1428
1909
  if (lifecycle.isCancelled())
1429
1910
  throw new Error('checkout cancelled locally after approval');
1430
1911
  try {
@@ -1434,25 +1915,35 @@ export async function attachToPlaywright(page, opts) {
1434
1915
  throw new PaymentOutcomeUnknownError(replay.authorizationId, 'mercado_checkout_metadata_invalid');
1435
1916
  }
1436
1917
  assertRequestLive();
1437
- lifecycle.prepareHandoff(replay, request.url());
1918
+ // The request to answer: the paused one, or the page's retry when the
1919
+ // page gave up on the first (see attachToCdp for the same step).
1920
+ const delivery = await attempt.awaitTarget(boundedRetryWait(retryWait, approvalStartedAt, opts.timeoutMs), [lifecycle.abort.signal, setupStop.signal], () => {
1921
+ lifecycle.awaitingMerchantRetry(replay.authorizationId);
1922
+ opts.onEvent?.({ type: 'approval_awaiting_merchant_retry', detail: { authorizationId: replay.authorizationId } });
1923
+ });
1924
+ const { route: deliverTo, request: deliverRequest } = delivery.target;
1925
+ const deliverBody = delivery.rebound ? deliverRequest.postData() ?? '' : body;
1926
+ deliveryBegun = true;
1927
+ attempt.beginDelivery();
1928
+ lifecycle.prepareHandoff(replay, deliverRequest.url());
1438
1929
  handoffStarted = replay.mode !== 'cse';
1439
1930
  if (replay.mode === 'hosted_form') {
1440
1931
  // Same as the CDP path: the paused navigation resolves to the
1441
1932
  // synthetic page, and a re-post of this form is refused.
1442
1933
  const synthetic = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
1443
1934
  // Inert on a navigation (never CORS-checked); one path for every fulfill.
1444
- await route.fulfill({ status: synthetic.status, headers: withCorsHeaders(synthetic.headers, corsHeadersFor(request.url(), request.headers())), body: synthetic.body });
1935
+ await deliverTo.fulfill({ status: synthetic.status, headers: withCorsHeaders(synthetic.headers, corsHeadersFor(deliverRequest.url(), deliverRequest.headers())), body: synthetic.body });
1445
1936
  assertRequestLive();
1446
- lastSubmitted = { url: request.url(), body, at: Date.now() };
1937
+ lastSubmitted = { url: deliverRequest.url(), body: deliverBody, at: Date.now() };
1447
1938
  opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
1448
1939
  }
1449
1940
  else if (replay.mode === 'cse') {
1450
1941
  // Same as the CDP path: the request continues from this browser
1451
1942
  // with the ciphertext swapped in and no header override; Playwright
1452
1943
  // recomputes the length itself.
1453
- const postData = cseBody(body, replay);
1944
+ const postData = cseBody(deliverBody, replay);
1454
1945
  handoffStarted = true;
1455
- await route.continue({ postData });
1946
+ await deliverTo.continue({ postData });
1456
1947
  assertRequestLive();
1457
1948
  opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
1458
1949
  }
@@ -1460,23 +1951,39 @@ export async function attachToPlaywright(page, opts) {
1460
1951
  // Playwright adds these itself when a cross-origin fulfill carries
1461
1952
  // none; written here anyway (replacing a stale value) so a
1462
1953
  // cross-origin answer is the same whichever adapter ran.
1463
- const cors = corsDecision(request.url(), request.headers());
1464
- await route.fulfill({ status: replay.status, headers: withCorsHeaders(replay.headers, cors.headers), body: replay.body });
1954
+ const cors = corsDecision(deliverRequest.url(), deliverRequest.headers());
1955
+ await deliverTo.fulfill({ status: replay.status, headers: withCorsHeaders(replay.headers, cors.headers), body: replay.body });
1465
1956
  assertRequestLive();
1466
1957
  opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
1467
1958
  }
1468
1959
  lifecycle.handedOff(replay);
1469
1960
  }
1470
1961
  catch (err) {
1962
+ const dead = attempt.target?.route ?? route;
1471
1963
  if (activeRequest?.attempt === attempt)
1472
1964
  activeRequest = null;
1473
- lifecycle.failed(err, handoffStarted);
1474
- if (isTerminal(err))
1475
- terminal = err;
1476
- else if (isApprovalOutcome(err))
1965
+ try {
1966
+ attempt.assertLive();
1967
+ }
1968
+ catch (stopped) {
1969
+ err = stopped;
1970
+ }
1971
+ if (err instanceof MerchantNeverRetried || (attempt.signal.aborted && approvedId && !deliveryBegun)) {
1972
+ // The device approved and no request ever carried the answer; see
1973
+ // the same branch in attachToCdp.
1974
+ await retireUnusedApproval(opts, lifecycle, approvedId);
1477
1975
  quietUntil = Date.now() + cooldownMs;
1478
- opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
1479
- await route.abort('aborted').catch(() => { });
1976
+ }
1977
+ else {
1978
+ if (!attempt.signal.aborted)
1979
+ lifecycle.failed(err, handoffStarted);
1980
+ if (isTerminal(err))
1981
+ terminal = err;
1982
+ else if (isApprovalOutcome(err))
1983
+ quietUntil = Date.now() + cooldownMs;
1984
+ opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
1985
+ }
1986
+ await dead.abort('aborted').catch(() => { });
1480
1987
  }
1481
1988
  finally {
1482
1989
  if (activeRequest?.attempt === attempt)