From 64db4cd9fbc978975461b5700da60f20a034b01a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Oct 2026 02:33:13 +0000 Subject: [PATCH 01/13] test(browser-pay): repro relay_in_progress failing the pause The paused CDP request is failed on the first relay_in_progress. The same idempotency key is not posted again. Co-authored-by: Rimmely Ewan --- .../src/interceptor.relay-in-progress.test.ts | 334 ++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 packages/browser-pay/src/interceptor.relay-in-progress.test.ts diff --git a/packages/browser-pay/src/interceptor.relay-in-progress.test.ts b/packages/browser-pay/src/interceptor.relay-in-progress.test.ts new file mode 100644 index 000000000..6ab6568c0 --- /dev/null +++ b/packages/browser-pay/src/interceptor.relay-in-progress.test.ts @@ -0,0 +1,334 @@ +import crypto from "node:crypto"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import type { Duplex } from "node:stream"; +import { paymentIdempotencyKey, type RelayRequest } from "./contract"; +import { attachInterceptor } from "./interceptor"; +import { httpPaymentRelay } from "./relay"; + +const HELD = { + number: "4242424242424242", + injectionRef: "inj_in_progress", +}; + +const CHECKOUT_URL = "https://checkout.example/sessions"; + +const CARD = JSON.stringify({ + credit_card: { + number: HELD.number, + month: 12, + year: 2028, + verification_value: "123", + }, +}); + +const TRANSACTION_ID = "tx_in_progress"; +const RELAY_PATH = `/v1/transactions/${TRANSACTION_ID}/payment/relay`; + +const RELAYED = { + outcome: "relayed" as const, + response: { + status: 201, + headers: { "content-type": "application/json" }, + body: '{"id":"sess"}', + }, +}; + +interface Command { + method: string; + params: Record; + sessionId?: string; +} + +interface SeenPost { + method: string; + url: string; + idempotencyKey: string; +} + +function serverFrame(text: string): Buffer { + const payload = Buffer.from(text); + if (payload.length < 126) { + return Buffer.concat([Buffer.from([0x81, payload.length]), payload]); + } + const header = Buffer.alloc(4); + header[0] = 0x81; + header[1] = 126; + header.writeUInt16BE(payload.length, 2); + return Buffer.concat([header, payload]); +} + +async function startCdp(): Promise<{ + url: string; + commands: Command[]; + emit: (event: Record) => void; + close: () => Promise; +}> { + const commands: Command[] = []; + let socket: Duplex | undefined; + const chunks: Buffer[] = []; + let buffered = 0; + + const reply = (id: number, result: unknown) => { + socket?.write(serverFrame(JSON.stringify({ id, result }))); + }; + + const consume = (length: number): Buffer | null => { + if (buffered < length) return null; + const out = Buffer.allocUnsafe(length); + let offset = 0; + while (offset < length) { + const head = chunks[0]; + const need = length - offset; + if (head.length <= need) { + head.copy(out, offset); + offset += head.length; + chunks.shift(); + } else { + head.copy(out, offset, 0, need); + chunks[0] = head.subarray(need); + offset += need; + } + } + buffered -= length; + return out; + }; + + const peek = (length: number): Buffer | null => { + if (buffered < length) return null; + if (chunks[0] && chunks[0].length >= length) { + return chunks[0].subarray(0, length); + } + const out = Buffer.allocUnsafe(length); + let offset = 0; + for (const chunk of chunks) { + const take = Math.min(chunk.length, length - offset); + chunk.copy(out, offset, 0, take); + offset += take; + if (offset >= length) break; + } + return out; + }; + + const onMessage = (text: string) => { + const message = JSON.parse(text) as { + id?: number; + method?: string; + params?: Record; + sessionId?: string; + }; + if (!message.id || !message.method) return; + commands.push({ + method: message.method, + params: message.params ?? {}, + ...(message.sessionId ? { sessionId: message.sessionId } : {}), + }); + if (message.method === "Target.getTargets") { + reply(message.id, { targetInfos: [] }); + return; + } + reply(message.id, {}); + }; + + const parse = () => { + for (;;) { + const header = peek(2); + if (!header) return; + const second = header[1]; + let length = second & 0x7f; + let offset = 2; + if (length === 126) { + const extended = peek(4); + if (!extended) return; + length = extended.readUInt16BE(2); + offset = 4; + } + const masked = (second & 0x80) !== 0; + const maskOffset = offset; + if (masked) offset += 4; + const frame = consume(offset + length); + if (!frame) return; + let payload = frame.subarray(offset); + if (masked) { + const mask = frame.subarray(maskOffset, maskOffset + 4); + const unmasked = Buffer.allocUnsafe(payload.length); + for (let index = 0; index < payload.length; index++) { + unmasked[index] = payload[index] ^ mask[index % 4]; + } + payload = unmasked; + } + if ((header[0] & 0x0f) === 0x8) return; + if ((header[0] & 0x0f) !== 0x1) continue; + onMessage(payload.toString("utf8")); + } + }; + + const server = http.createServer(); + server.on("upgrade", (request, upgraded, head) => { + const key = String(request.headers["sec-websocket-key"] ?? ""); + const accept = crypto + .createHash("sha1") + .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) + .digest("base64"); + upgraded.write( + `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`, + ); + socket = upgraded; + if (head?.length) { + chunks.push(Buffer.from(head)); + buffered += head.length; + } + upgraded.on("data", (chunk: Buffer) => { + chunks.push(chunk); + buffered += chunk.length; + parse(); + }); + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + const address = server.address() as AddressInfo; + return { + url: `ws://127.0.0.1:${address.port}`, + commands, + emit(event) { + socket?.write(serverFrame(JSON.stringify(event))); + }, + close() { + socket?.destroy(); + return new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +function startRelay(): Promise<{ + origin: string; + posts: SeenPost[]; + close: () => Promise; +}> { + const posts: SeenPost[] = []; + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + const body = JSON.parse( + Buffer.concat(chunks).toString("utf8"), + ) as RelayRequest; + posts.push({ + method: req.method ?? "", + url: req.url ?? "", + idempotencyKey: body.idempotencyKey, + }); + const payload = + posts.length === 1 + ? { outcome: "refused", reason: "relay_in_progress" } + : RELAYED; + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(payload)); + }); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address() as AddressInfo; + resolve({ + origin: `http://127.0.0.1:${address.port}`, + posts, + close: () => new Promise((done) => server.close(() => done())), + }); + }); + }); +} + +async function waitFor(predicate: () => boolean, label: string): Promise { + const deadline = Date.now() + 3_000; + while (!predicate()) { + if (Date.now() > deadline) { + throw new Error(`timed out waiting for ${label}`); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +function count(commands: Command[], method: string): number { + return commands.filter((command) => command.method === method).length; +} + +describe("relay_in_progress", () => { + it("holds the pause, posts the same idempotency key again, and fulfills", async () => { + const idempotencyKey = paymentIdempotencyKey("POST", CHECKOUT_URL, CARD); + const cdp = await startCdp(); + const relay = await startRelay(); + const handle = await attachInterceptor(cdp.url, { + relay: httpPaymentRelay({ + baseUrl: relay.origin, + apiKey: "test-key", + transactionId: TRANSACTION_ID, + }), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + try { + cdp.emit({ + method: "Target.attachedToTarget", + params: { + sessionId: "page-1", + waitingForDebugger: true, + targetInfo: { + targetId: "target-page", + type: "page", + url: "https://shop.example/checkout", + }, + }, + }); + await waitFor( + () => cdp.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + cdp.emit({ + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId: "req-in-progress", + request: { + url: CHECKOUT_URL, + method: "POST", + headers: { "content-type": "application/json" }, + postData: CARD, + }, + }, + }); + await waitFor( + () => + cdp.commands.some( + (command) => + command.method === "Fetch.fulfillRequest" || + command.method === "Fetch.failRequest", + ), + "Fetch settlement", + ); + expect({ + relayPosts: relay.posts, + fetch: { + failRequest: count(cdp.commands, "Fetch.failRequest"), + fulfillRequest: count(cdp.commands, "Fetch.fulfillRequest"), + continueRequest: count(cdp.commands, "Fetch.continueRequest"), + }, + }).toEqual({ + relayPosts: [ + { method: "POST", url: RELAY_PATH, idempotencyKey }, + { method: "POST", url: RELAY_PATH, idempotencyKey }, + ], + fetch: { + failRequest: 0, + fulfillRequest: 1, + continueRequest: 0, + }, + }); + } finally { + await handle.detach(); + await cdp.close(); + await relay.close(); + } + }); +}); From 7f9b150101c0ce8d723885f5f398c1ab0aa76cab Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Oct 2026 02:34:40 +0000 Subject: [PATCH 02/13] fix(browser-pay): poll the same key on relay_in_progress Keep the CDP request paused and post the same idempotency key again, with a bounded backoff, until the relay outcome is terminal or the deadline passes. Co-authored-by: Rimmely Ewan --- .changeset/browser-pay-relay-in-progress.md | 5 + packages/browser-pay/README.md | 6 +- .../src/interceptor.relay-in-progress.test.ts | 233 +++++++++++++++++- packages/browser-pay/src/interceptor.ts | 71 +++++- 4 files changed, 292 insertions(+), 23 deletions(-) create mode 100644 .changeset/browser-pay-relay-in-progress.md diff --git a/.changeset/browser-pay-relay-in-progress.md b/.changeset/browser-pay-relay-in-progress.md new file mode 100644 index 000000000..5e2b06c3f --- /dev/null +++ b/.changeset/browser-pay-relay-in-progress.md @@ -0,0 +1,5 @@ +--- +"@sapiom/browser-pay": patch +--- + +Hold a paused card request and post the same idempotency key again when the relay returns `relay_in_progress`, until the outcome is terminal or the relay deadline passes. diff --git a/packages/browser-pay/README.md b/packages/browser-pay/README.md index 855a2fa6a..4318d0ca5 100644 --- a/packages/browser-pay/README.md +++ b/packages/browser-pay/README.md @@ -9,7 +9,7 @@ Browser-side payment interceptor. The agent types a governance placeholder into 3. The agent opens a browser session and drives checkout, typing the placeholder. 4. `attachInterceptor` pauses the card request, including inside a payment iframe. 5. A match is `POST /v1/transactions/{transactionId}/payment/relay`. -6. The page receives the processor status, headers, and body. A refusal, an unknown outcome, or a relay error fails the paused request. +6. The page receives the processor status, headers, and body. A terminal refusal, an unknown outcome, or a relay error fails the paused request. `relay_in_progress` keeps the request paused and asks again with the same idempotency key. ## Relay contract @@ -53,7 +53,7 @@ Refusal, nothing forwarded: { "outcome": "refused", "reason": "already_relayed" } ``` -The reason string is passed through. Reasons David returns include `already_relayed`, `total_changed`, `key_rotated`, `card_removed`, `unknown_injection`, `injection_expired`, `payment_released`, `relay_in_progress`, `payment_not_authorized`, `destination_not_allowed`, `card_fields_invalid`, `merchant_mismatch`, and `provider_unavailable`. +The reason string is passed through. Reasons David returns include `already_relayed`, `total_changed`, `key_rotated`, `card_removed`, `unknown_injection`, `injection_expired`, `payment_released`, `relay_in_progress`, `payment_not_authorized`, `destination_not_allowed`, `card_fields_invalid`, `merchant_mismatch`, and `provider_unavailable`. `relay_in_progress` is not terminal. The paused request stays open and the same idempotency key is posted again until the outcome is terminal or the deadline passes. No answer after the send: @@ -87,7 +87,7 @@ A request is relayed when the held placeholder number appears in the URL or body A relayed response fails the paused request only when that response contains the held placeholder number. -Identical in-flight retries (same method, URL, and body) share one relay call. `AttachOptions.timeoutMs` is the only deadline on that call. It defaults to 20 seconds. A relay that ignores abort still fails when the deadline passes, and one waiter's timeout does not abort a later waiter of the same flight. A refusal is logged once. A transport failure drops the key so a later retry can resend. `outcome: "unknown"` does not. +Identical in-flight retries (same method, URL, and body) share one relay call. `AttachOptions.timeoutMs` is the deadline for a terminal outcome. It defaults to 20 seconds. While the relay answers `relay_in_progress`, the same key is posted again after a bounded backoff. At the deadline the paused request fails with `timeout`. A relay that ignores abort still fails when the deadline passes, and one waiter's timeout does not abort a later waiter of the same flight. A terminal refusal is logged once. A transport failure drops the key so a later retry can resend. `outcome: "unknown"` does not. Captcha widgets, extension pages, and workers are not paused. A blank iframe, including `about:blank`, stays paused so a navigation onto the card URL cannot slip through. A placeholder that shows up on any other page or iframe is relayed. diff --git a/packages/browser-pay/src/interceptor.relay-in-progress.test.ts b/packages/browser-pay/src/interceptor.relay-in-progress.test.ts index 6ab6568c0..f701da8ad 100644 --- a/packages/browser-pay/src/interceptor.relay-in-progress.test.ts +++ b/packages/browser-pay/src/interceptor.relay-in-progress.test.ts @@ -2,8 +2,8 @@ import crypto from "node:crypto"; import http from "node:http"; import type { AddressInfo } from "node:net"; import type { Duplex } from "node:stream"; -import { paymentIdempotencyKey, type RelayRequest } from "./contract"; -import { attachInterceptor } from "./interceptor"; +import { paymentIdempotencyKey, type RelayRequest, type RelayResponse } from "./contract"; +import { attachInterceptor, type InterceptorEvent } from "./interceptor"; import { httpPaymentRelay } from "./relay"; const HELD = { @@ -34,6 +34,11 @@ const RELAYED = { }, }; +const IN_PROGRESS: RelayResponse = { + outcome: "refused", + reason: "relay_in_progress", +}; + interface Command { method: string; params: Record; @@ -202,7 +207,9 @@ async function startCdp(): Promise<{ }; } -function startRelay(): Promise<{ +function startRelay( + script: RelayResponse[] = [IN_PROGRESS, RELAYED], +): Promise<{ origin: string; posts: SeenPost[]; close: () => Promise; @@ -220,10 +227,7 @@ function startRelay(): Promise<{ url: req.url ?? "", idempotencyKey: body.idempotencyKey, }); - const payload = - posts.length === 1 - ? { outcome: "refused", reason: "relay_in_progress" } - : RELAYED; + const payload = script[Math.min(posts.length, script.length) - 1]; res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(payload)); }); @@ -331,4 +335,219 @@ describe("relay_in_progress", () => { await relay.close(); } }); + + it("fails with timeout when relay_in_progress outlasts the deadline", async () => { + const idempotencyKey = paymentIdempotencyKey("POST", CHECKOUT_URL, CARD); + const cdp = await startCdp(); + const relay = await startRelay([IN_PROGRESS]); + const events: InterceptorEvent[] = []; + const handle = await attachInterceptor(cdp.url, { + relay: httpPaymentRelay({ + baseUrl: relay.origin, + apiKey: "test-key", + transactionId: TRANSACTION_ID, + }), + placeholder: HELD, + profileId: "shopify-card-vault", + timeoutMs: 700, + }); + handle.on((event) => events.push(event)); + try { + cdp.emit({ + method: "Target.attachedToTarget", + params: { + sessionId: "page-1", + waitingForDebugger: true, + targetInfo: { + targetId: "target-page", + type: "page", + url: "https://shop.example/checkout", + }, + }, + }); + await waitFor( + () => cdp.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + cdp.emit({ + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId: "req-deadline", + request: { + url: CHECKOUT_URL, + method: "POST", + headers: { "content-type": "application/json" }, + postData: CARD, + }, + }, + }); + await waitFor( + () => events.some((event) => event.type === "failed"), + "timeout", + ); + expect(relay.posts.length).toBeGreaterThanOrEqual(2); + expect(relay.posts.every((post) => post.idempotencyKey === idempotencyKey)).toBe( + true, + ); + expect(count(cdp.commands, "Fetch.failRequest")).toBe(1); + expect(count(cdp.commands, "Fetch.fulfillRequest")).toBe(0); + expect(events).toContainEqual( + expect.objectContaining({ type: "failed", reason: "timeout" }), + ); + } finally { + await handle.detach(); + await cdp.close(); + await relay.close(); + } + }); + + it("fails a terminal refusal after relay_in_progress", async () => { + const idempotencyKey = paymentIdempotencyKey("POST", CHECKOUT_URL, CARD); + const cdp = await startCdp(); + const relay = await startRelay([ + IN_PROGRESS, + { outcome: "refused", reason: "total_changed" }, + ]); + const events: InterceptorEvent[] = []; + const handle = await attachInterceptor(cdp.url, { + relay: httpPaymentRelay({ + baseUrl: relay.origin, + apiKey: "test-key", + transactionId: TRANSACTION_ID, + }), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + handle.on((event) => events.push(event)); + try { + cdp.emit({ + method: "Target.attachedToTarget", + params: { + sessionId: "page-1", + waitingForDebugger: true, + targetInfo: { + targetId: "target-page", + type: "page", + url: "https://shop.example/checkout", + }, + }, + }); + await waitFor( + () => cdp.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + cdp.emit({ + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId: "req-refused", + request: { + url: CHECKOUT_URL, + method: "POST", + headers: { "content-type": "application/json" }, + postData: CARD, + }, + }, + }); + await waitFor( + () => events.some((event) => event.type === "refused"), + "refused", + ); + expect(relay.posts).toEqual([ + { method: "POST", url: RELAY_PATH, idempotencyKey }, + { method: "POST", url: RELAY_PATH, idempotencyKey }, + ]); + expect(count(cdp.commands, "Fetch.failRequest")).toBe(1); + expect(count(cdp.commands, "Fetch.fulfillRequest")).toBe(0); + expect(events).toContainEqual( + expect.objectContaining({ type: "refused", reason: "total_changed" }), + ); + } finally { + await handle.detach(); + await cdp.close(); + await relay.close(); + } + }); + + it("remembers unknown after relay_in_progress and does not post it again", async () => { + const idempotencyKey = paymentIdempotencyKey("POST", CHECKOUT_URL, CARD); + const cdp = await startCdp(); + const relay = await startRelay([IN_PROGRESS, { outcome: "unknown" }]); + const events: InterceptorEvent[] = []; + const handle = await attachInterceptor(cdp.url, { + relay: httpPaymentRelay({ + baseUrl: relay.origin, + apiKey: "test-key", + transactionId: TRANSACTION_ID, + }), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + handle.on((event) => events.push(event)); + try { + cdp.emit({ + method: "Target.attachedToTarget", + params: { + sessionId: "page-1", + waitingForDebugger: true, + targetInfo: { + targetId: "target-page", + type: "page", + url: "https://shop.example/checkout", + }, + }, + }); + await waitFor( + () => cdp.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + cdp.emit({ + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId: "req-unknown", + request: { + url: CHECKOUT_URL, + method: "POST", + headers: { "content-type": "application/json" }, + postData: CARD, + }, + }, + }); + await waitFor( + () => events.some((event) => event.type === "failed"), + "unknown", + ); + cdp.emit({ + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId: "req-unknown-again", + request: { + url: CHECKOUT_URL, + method: "POST", + headers: { "content-type": "application/json" }, + postData: CARD, + }, + }, + }); + await waitFor( + () => count(cdp.commands, "Fetch.failRequest") === 2, + "second fail", + ); + expect(relay.posts).toEqual([ + { method: "POST", url: RELAY_PATH, idempotencyKey }, + { method: "POST", url: RELAY_PATH, idempotencyKey }, + ]); + expect(count(cdp.commands, "Fetch.fulfillRequest")).toBe(0); + expect(events).toContainEqual( + expect.objectContaining({ type: "failed", reason: "unknown" }), + ); + } finally { + await handle.detach(); + await cdp.close(); + await relay.close(); + } + }); }); diff --git a/packages/browser-pay/src/interceptor.ts b/packages/browser-pay/src/interceptor.ts index 1f73d4721..bd7c2a67f 100644 --- a/packages/browser-pay/src/interceptor.ts +++ b/packages/browser-pay/src/interceptor.ts @@ -24,10 +24,12 @@ export interface AttachOptions { /** Relay profile, for example `shopify-card-vault`. */ profileId: string; /** - * How long to wait for the relay before failing the paused request. - * The relay is not retried. `outcome: "unknown"` is not sent again. - * A relay that ignores the abort signal still fails here. One waiter's - * timeout does not abort a shared flight that another waiter still holds. + * How long to wait for a terminal relay outcome before failing the paused + * request. `relay_in_progress` is posted again with the same idempotency + * key until this deadline. Any other refusal fails the request. + * `outcome: "unknown"` is not sent again. A relay that ignores the abort + * signal still fails here. One waiter's timeout does not abort a shared + * flight that another waiter still holds. */ timeoutMs?: number; /** Defaults to a logger that discards every line. */ @@ -69,6 +71,8 @@ export type InterceptorTarget = | { context(): { browser(): { wsEndpoint(): string } | null } | null }; const DEFAULT_TIMEOUT_MS = 20_000; +const RELAY_POLL_INITIAL_MS = 200; +const RELAY_POLL_MAX_MS = 2_000; const TARGET_FILTER = [{ type: "page" }, { type: "iframe" }]; const CHILD_FILTER = [{ type: "iframe" }]; @@ -523,14 +527,37 @@ class PaymentInterceptor { if (this.unknownKeys.has(idempotencyKey)) { return { action: "fail", reason: "unknown" }; } - const relayed = await this.callRelay( - request, - read.body, - pageOrigin, - idempotencyKey, - ); - if (relayed.action === "fail") return relayed; - return this.decisionForOutcome(relayed.response, idempotencyKey); + return this.awaitRelay(request, read.body, pageOrigin, idempotencyKey); + } + + private async awaitRelay( + request: PausedRequest, + body: string, + pageOrigin: string, + idempotencyKey: string, + ): Promise { + const deadline = Date.now() + this.timeoutMs; + let poll = 0; + for (;;) { + if (this.closed) return { action: "fail", reason: "detached" }; + const remaining = deadline - Date.now(); + if (remaining <= 0) return { action: "fail", reason: "timeout" }; + const relayed = await this.callRelay( + request, + body, + pageOrigin, + idempotencyKey, + remaining, + ); + if (relayed.action === "fail") return relayed; + if (!isRelayInProgress(relayed.response)) { + return this.decisionForOutcome(relayed.response, idempotencyKey); + } + poll += 1; + const waitMs = Math.min(relayPollDelayMs(poll), deadline - Date.now()); + if (waitMs <= 0) return { action: "fail", reason: "timeout" }; + await sleep(waitMs); + } } private async readHeld( @@ -555,6 +582,7 @@ class PaymentInterceptor { body: string, pageOrigin: string, idempotencyKey: string, + budgetMs: number, ): Promise< | { action: "ok"; response: RelayResponse } | { action: "fail"; reason: FailureReason } @@ -577,7 +605,7 @@ class PaymentInterceptor { ), ); try { - const response = await failAfter(flight.promise, this.timeoutMs); + const response = await failAfter(flight.promise, budgetMs); this.releaseWaiter(flight); return { action: "ok", response }; } catch (error) { @@ -784,6 +812,23 @@ function inflightKey(sessionId: string, requestId: string): string { return `${sessionId}\n${requestId}`; } +function isRelayInProgress(response: RelayResponse): boolean { + return ( + response.outcome === "refused" && response.reason === "relay_in_progress" + ); +} + +function relayPollDelayMs(attempt: number): number { + const shift = Math.min(Math.max(attempt, 1) - 1, 10); + return Math.min(RELAY_POLL_INITIAL_MS * 2 ** shift, RELAY_POLL_MAX_MS); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + function failureReason(error: unknown): FailureReason { return isRelayTimeout(error) ? "timeout" : "network"; } From fb47dd88fb63228f1d88d0899b11166750c5d41c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Oct 2026 02:35:02 +0000 Subject: [PATCH 03/13] wip(browser-pay): retry the relay until the total deadline Placeholder so the stacked draft exists. The retry behavior is not in this commit. Co-authored-by: Rimmely Ewan From 11b222a8c4604db9fe55a7549942a2d8980c3123 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Oct 2026 02:46:48 +0000 Subject: [PATCH 04/13] style(browser-pay): format the relay_in_progress test Co-authored-by: Rimmely Ewan --- .../src/interceptor.relay-in-progress.test.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/browser-pay/src/interceptor.relay-in-progress.test.ts b/packages/browser-pay/src/interceptor.relay-in-progress.test.ts index f701da8ad..7bf642923 100644 --- a/packages/browser-pay/src/interceptor.relay-in-progress.test.ts +++ b/packages/browser-pay/src/interceptor.relay-in-progress.test.ts @@ -2,7 +2,11 @@ import crypto from "node:crypto"; import http from "node:http"; import type { AddressInfo } from "node:net"; import type { Duplex } from "node:stream"; -import { paymentIdempotencyKey, type RelayRequest, type RelayResponse } from "./contract"; +import { + paymentIdempotencyKey, + type RelayRequest, + type RelayResponse, +} from "./contract"; import { attachInterceptor, type InterceptorEvent } from "./interceptor"; import { httpPaymentRelay } from "./relay"; @@ -207,9 +211,7 @@ async function startCdp(): Promise<{ }; } -function startRelay( - script: RelayResponse[] = [IN_PROGRESS, RELAYED], -): Promise<{ +function startRelay(script: RelayResponse[] = [IN_PROGRESS, RELAYED]): Promise<{ origin: string; posts: SeenPost[]; close: () => Promise; @@ -387,9 +389,9 @@ describe("relay_in_progress", () => { "timeout", ); expect(relay.posts.length).toBeGreaterThanOrEqual(2); - expect(relay.posts.every((post) => post.idempotencyKey === idempotencyKey)).toBe( - true, - ); + expect( + relay.posts.every((post) => post.idempotencyKey === idempotencyKey), + ).toBe(true); expect(count(cdp.commands, "Fetch.failRequest")).toBe(1); expect(count(cdp.commands, "Fetch.fulfillRequest")).toBe(0); expect(events).toContainEqual( From 42013e4875974970d60f5e82679fe340a3678aa3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Oct 2026 02:49:49 +0000 Subject: [PATCH 05/13] fix(browser-pay): retry the relay until the total deadline A per-attempt timeout, a transport error, or a router 502, 503, or 504 posts the same idempotency key again until the outcome is terminal or the total deadline passes. One attempt defaults to 25s. The total wait defaults to 55s. Co-authored-by: Rimmely Ewan --- .changeset/browser-pay-relay-retry.md | 5 + packages/browser-pay/README.md | 4 +- .../src/interceptor.relay-in-progress.test.ts | 248 ++++++++++++++++++ packages/browser-pay/src/interceptor.ts | 45 +++- packages/browser-pay/src/relay.test.ts | 34 ++- packages/browser-pay/src/relay.ts | 28 +- 6 files changed, 345 insertions(+), 19 deletions(-) create mode 100644 .changeset/browser-pay-relay-retry.md diff --git a/.changeset/browser-pay-relay-retry.md b/.changeset/browser-pay-relay-retry.md new file mode 100644 index 000000000..fb1418cf4 --- /dev/null +++ b/.changeset/browser-pay-relay-retry.md @@ -0,0 +1,5 @@ +--- +"@sapiom/browser-pay": patch +--- + +Retry a paused card relay on attempt timeout, transport failure, and router HTTP 502, 503, or 504, using the same idempotency key, until the total deadline. One attempt defaults to 25 seconds. The total wait defaults to 55 seconds. diff --git a/packages/browser-pay/README.md b/packages/browser-pay/README.md index 4318d0ca5..0a618109f 100644 --- a/packages/browser-pay/README.md +++ b/packages/browser-pay/README.md @@ -63,7 +63,7 @@ No answer after the send: The injection is consumed. The paused request fails and the same body is not sent again. -A transport error, a redirect, a non-2xx HTTP status, or an unreadable body fails the paused browser request. David's relay returns HTTP 200 for every outcome, so a 401 or 500 is not read as `unknown` or `refused`. It is not fulfilled with the error JSON. If fulfill fails, the paused request is failed and the agent event is `relayed_unfulfilled`. +A redirect, an HTTP 401 or 500, or an unreadable body fails the paused browser request. David's relay returns HTTP 200 for every outcome, so a 401 or 500 is not read as `unknown` or `refused`. It is not fulfilled with the error JSON. A transport error, a per-attempt timeout, and a router HTTP 502, 503, or 504 are posted again with the same idempotency key. If fulfill fails, the paused request is failed and the agent event is `relayed_unfulfilled`. ## What an agent does @@ -87,7 +87,7 @@ A request is relayed when the held placeholder number appears in the URL or body A relayed response fails the paused request only when that response contains the held placeholder number. -Identical in-flight retries (same method, URL, and body) share one relay call. `AttachOptions.timeoutMs` is the deadline for a terminal outcome. It defaults to 20 seconds. While the relay answers `relay_in_progress`, the same key is posted again after a bounded backoff. At the deadline the paused request fails with `timeout`. A relay that ignores abort still fails when the deadline passes, and one waiter's timeout does not abort a later waiter of the same flight. A terminal refusal is logged once. A transport failure drops the key so a later retry can resend. `outcome: "unknown"` does not. +Identical in-flight retries (same method, URL, and body) share one relay call. `AttachOptions.timeoutMs` is the deadline for a terminal outcome. It defaults to 55 seconds, enough for two attempts inside a handler budget under 30 seconds. `AttachOptions.attemptTimeoutMs` is one POST. It defaults to 25 seconds so the client aborts before a router cuts the socket at 30 seconds. While the relay answers `relay_in_progress`, or an attempt times out, or the transport fails, or the router returns 502, 503, or 504, the same key is posted again after a bounded backoff. At the deadline the paused request fails with `timeout`. One waiter's timeout does not abort a later waiter of the same flight. A terminal refusal is logged once. `outcome: "unknown"` is not sent again. Captcha widgets, extension pages, and workers are not paused. A blank iframe, including `about:blank`, stays paused so a navigation onto the card URL cannot slip through. A placeholder that shows up on any other page or iframe is relayed. diff --git a/packages/browser-pay/src/interceptor.relay-in-progress.test.ts b/packages/browser-pay/src/interceptor.relay-in-progress.test.ts index 7bf642923..2549afb6f 100644 --- a/packages/browser-pay/src/interceptor.relay-in-progress.test.ts +++ b/packages/browser-pay/src/interceptor.relay-in-progress.test.ts @@ -553,3 +553,251 @@ describe("relay_in_progress", () => { } }); }); + +function listenRelay( + respond: ( + index: number, + req: http.IncomingMessage, + res: http.ServerResponse, + ) => void, +): Promise<{ + origin: string; + posts: SeenPost[]; + close: () => Promise; +}> { + const posts: SeenPost[] = []; + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + const body = JSON.parse( + Buffer.concat(chunks).toString("utf8"), + ) as RelayRequest; + posts.push({ + method: req.method ?? "", + url: req.url ?? "", + idempotencyKey: body.idempotencyKey, + }); + respond(posts.length, req, res); + }); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address() as AddressInfo; + resolve({ + origin: `http://127.0.0.1:${address.port}`, + posts, + close: () => + new Promise((done) => { + server.closeAllConnections(); + server.close(() => done()); + }), + }); + }); + }); +} + +async function driveHeldPost(options: { + origin: string; + timeoutMs: number; + attemptTimeoutMs?: number; + until: (events: InterceptorEvent[], commands: Command[]) => boolean; + label: string; +}): Promise<{ events: InterceptorEvent[]; commands: Command[] }> { + const cdp = await startCdp(); + const events: InterceptorEvent[] = []; + const handle = await attachInterceptor(cdp.url, { + relay: httpPaymentRelay({ + baseUrl: options.origin, + apiKey: "test-key", + transactionId: TRANSACTION_ID, + }), + placeholder: HELD, + profileId: "shopify-card-vault", + timeoutMs: options.timeoutMs, + ...(options.attemptTimeoutMs === undefined + ? {} + : { attemptTimeoutMs: options.attemptTimeoutMs }), + }); + handle.on((event) => events.push(event)); + try { + cdp.emit({ + method: "Target.attachedToTarget", + params: { + sessionId: "page-1", + waitingForDebugger: true, + targetInfo: { + targetId: "target-page", + type: "page", + url: "https://shop.example/checkout", + }, + }, + }); + await waitFor( + () => cdp.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + cdp.emit({ + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId: "req-retry", + request: { + url: CHECKOUT_URL, + method: "POST", + headers: { "content-type": "application/json" }, + postData: CARD, + }, + }, + }); + await waitFor(() => options.until(events, cdp.commands), options.label); + return { events, commands: cdp.commands }; + } finally { + await handle.detach(); + await cdp.close(); + } +} + +function sameKey(posts: SeenPost[], idempotencyKey: string): void { + expect(posts.length).toBeGreaterThan(0); + expect(posts.every((post) => post.idempotencyKey === idempotencyKey)).toBe( + true, + ); + expect( + posts.every((post) => post.method === "POST" && post.url === RELAY_PATH), + ).toBe(true); +} + +describe("relay retry before the deadline", () => { + const idempotencyKey = paymentIdempotencyKey("POST", CHECKOUT_URL, CARD); + + it("retries the same key after an attempt timeout and fulfills", async () => { + const relay = await listenRelay((index, _req, res) => { + if (index === 1) return; + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(RELAYED)); + }); + try { + const settled = await driveHeldPost({ + origin: relay.origin, + timeoutMs: 2_000, + attemptTimeoutMs: 80, + until: (_events, commands) => + commands.some((command) => command.method === "Fetch.fulfillRequest"), + label: "fulfill after timeout", + }); + expect(relay.posts.map((post) => post.idempotencyKey)).toEqual([ + idempotencyKey, + idempotencyKey, + ]); + expect(count(settled.commands, "Fetch.failRequest")).toBe(0); + expect(count(settled.commands, "Fetch.fulfillRequest")).toBe(1); + } finally { + await relay.close(); + } + }); + + it("retries the same key after HTTP 503 and fulfills", async () => { + const relay = await listenRelay((index, _req, res) => { + if (index === 1) { + res.writeHead(503); + res.end("unavailable"); + return; + } + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(RELAYED)); + }); + try { + const settled = await driveHeldPost({ + origin: relay.origin, + timeoutMs: 2_000, + until: (_events, commands) => + commands.some((command) => command.method === "Fetch.fulfillRequest"), + label: "fulfill after 503", + }); + expect(relay.posts.map((post) => post.idempotencyKey)).toEqual([ + idempotencyKey, + idempotencyKey, + ]); + expect(count(settled.commands, "Fetch.failRequest")).toBe(0); + expect(count(settled.commands, "Fetch.fulfillRequest")).toBe(1); + } finally { + await relay.close(); + } + }); + + it("retries the same key after a transport error and fulfills", async () => { + const relay = await listenRelay((index, req, res) => { + if (index === 1) { + req.socket?.destroy(); + return; + } + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(RELAYED)); + }); + try { + const settled = await driveHeldPost({ + origin: relay.origin, + timeoutMs: 2_000, + until: (_events, commands) => + commands.some((command) => command.method === "Fetch.fulfillRequest"), + label: "fulfill after transport error", + }); + sameKey(relay.posts, idempotencyKey); + expect(relay.posts).toHaveLength(2); + expect(count(settled.commands, "Fetch.fulfillRequest")).toBe(1); + expect(count(settled.commands, "Fetch.failRequest")).toBe(0); + } finally { + await relay.close(); + } + }); + + it("fails with timeout when retries exhaust the deadline", async () => { + const relay = await listenRelay((_index, _req, res) => { + res.writeHead(503); + res.end("unavailable"); + }); + try { + const settled = await driveHeldPost({ + origin: relay.origin, + timeoutMs: 900, + until: (events) => events.some((event) => event.type === "failed"), + label: "deadline", + }); + sameKey(relay.posts, idempotencyKey); + expect(relay.posts.length).toBeGreaterThanOrEqual(2); + expect(count(settled.commands, "Fetch.failRequest")).toBe(1); + expect(count(settled.commands, "Fetch.fulfillRequest")).toBe(0); + expect(settled.events).toContainEqual( + expect.objectContaining({ type: "failed", reason: "timeout" }), + ); + } finally { + await relay.close(); + } + }); + + it("does not retry an HTTP 500", async () => { + const relay = await listenRelay((_index, _req, res) => { + res.writeHead(500); + res.end("application error"); + }); + try { + const settled = await driveHeldPost({ + origin: relay.origin, + timeoutMs: 2_000, + until: (events) => events.some((event) => event.type === "failed"), + label: "500", + }); + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(relay.posts.map((post) => post.idempotencyKey)).toEqual([ + idempotencyKey, + ]); + expect(count(settled.commands, "Fetch.failRequest")).toBe(1); + expect(settled.events).toContainEqual( + expect.objectContaining({ type: "failed", reason: "network" }), + ); + } finally { + await relay.close(); + } + }); +}); diff --git a/packages/browser-pay/src/interceptor.ts b/packages/browser-pay/src/interceptor.ts index bd7c2a67f..fb2cc2324 100644 --- a/packages/browser-pay/src/interceptor.ts +++ b/packages/browser-pay/src/interceptor.ts @@ -13,6 +13,7 @@ import { isRecord } from "./guards.js"; import { silentLogger, type PayLogger } from "./logger.js"; import { isRelayTimeout, + isRetryableRelayError, RelayTimeoutError, type PaymentRelay, } from "./relay.js"; @@ -25,13 +26,19 @@ export interface AttachOptions { profileId: string; /** * How long to wait for a terminal relay outcome before failing the paused - * request. `relay_in_progress` is posted again with the same idempotency - * key until this deadline. Any other refusal fails the request. - * `outcome: "unknown"` is not sent again. A relay that ignores the abort - * signal still fails here. One waiter's timeout does not abort a shared - * flight that another waiter still holds. + * request. Defaults to 55 seconds. `relay_in_progress`, a per-attempt + * timeout, a transport error, and HTTP 502, 503, and 504 are posted again + * with the same idempotency key until this deadline. Any other refusal + * fails the request. `outcome: "unknown"` is not sent again. One waiter's + * timeout does not abort a shared flight that another waiter still holds. */ timeoutMs?: number; + /** + * How long one relay POST may run before it is aborted and tried again. + * Defaults to 25 seconds, under the 30 second router limit. The total + * wait is `timeoutMs`. + */ + attemptTimeoutMs?: number; /** Defaults to a logger that discards every line. */ logger?: PayLogger; } @@ -70,7 +77,8 @@ export type InterceptorTarget = | { browser(): { wsEndpoint(): string } | null } | { context(): { browser(): { wsEndpoint(): string } | null } | null }; -const DEFAULT_TIMEOUT_MS = 20_000; +const DEFAULT_ATTEMPT_TIMEOUT_MS = 25_000; +const DEFAULT_TIMEOUT_MS = 55_000; const RELAY_POLL_INITIAL_MS = 200; const RELAY_POLL_MAX_MS = 2_000; @@ -224,6 +232,7 @@ class PaymentInterceptor { private readonly loggedRefusals = new Set(); private readonly pendingArms = new Set>(); private readonly timeoutMs: number; + private readonly attemptTimeoutMs: number; private readonly logger: PayLogger; private backlog: InterceptorEvent[] | null = []; private buffering = true; @@ -234,6 +243,8 @@ class PaymentInterceptor { private readonly options: AttachOptions, ) { this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + this.attemptTimeoutMs = + options.attemptTimeoutMs ?? DEFAULT_ATTEMPT_TIMEOUT_MS; this.logger = options.logger ?? silentLogger; } @@ -547,12 +558,12 @@ class PaymentInterceptor { body, pageOrigin, idempotencyKey, - remaining, + Math.min(this.attemptTimeoutMs, remaining), ); - if (relayed.action === "fail") return relayed; - if (!isRelayInProgress(relayed.response)) { + if (relayed.action === "ok" && !isRelayInProgress(relayed.response)) { return this.decisionForOutcome(relayed.response, idempotencyKey); } + if (relayed.action === "fail" && !relayed.retryable) return relayed; poll += 1; const waitMs = Math.min(relayPollDelayMs(poll), deadline - Date.now()); if (waitMs <= 0) return { action: "fail", reason: "timeout" }; @@ -585,7 +596,7 @@ class PaymentInterceptor { budgetMs: number, ): Promise< | { action: "ok"; response: RelayResponse } - | { action: "fail"; reason: FailureReason } + | { action: "fail"; reason: FailureReason; retryable: boolean } > { const flight = this.relayOnce(idempotencyKey, (signal) => this.options.relay.relay( @@ -609,10 +620,15 @@ class PaymentInterceptor { this.releaseWaiter(flight); return { action: "ok", response }; } catch (error) { - this.releaseWaiter(flight); + const aborted = this.releaseWaiter(flight); + if (this.closed) { + return { action: "fail", reason: "detached", retryable: false }; + } return { action: "fail", - reason: this.closed ? "detached" : failureReason(error), + reason: failureReason(error), + retryable: + isRetryableRelayError(error) && (!isRelayTimeout(error) || aborted), }; } } @@ -744,10 +760,11 @@ class PaymentInterceptor { return flight; } - private releaseWaiter(flight: Flight): void { + private releaseWaiter(flight: Flight): boolean { flight.waiters -= 1; - if (flight.waiters > 0) return; + if (flight.waiters > 0) return false; flight.controller.abort(); + return true; } private async detach(): Promise { diff --git a/packages/browser-pay/src/relay.test.ts b/packages/browser-pay/src/relay.test.ts index 0f52a77f3..2b2b55d0f 100644 --- a/packages/browser-pay/src/relay.test.ts +++ b/packages/browser-pay/src/relay.test.ts @@ -1,7 +1,11 @@ import http from "node:http"; import type { AddressInfo } from "node:net"; import type { RelayRequest } from "./contract"; -import { httpPaymentRelay } from "./relay"; +import { + httpPaymentRelay, + isRetryableRelayError, + RelayHttpError, +} from "./relay"; const REQUEST: RelayRequest = { injectionRef: "inj_fixture_shopify", @@ -251,6 +255,34 @@ describe("httpPaymentRelay", () => { controller.abort(); await expectation; }); + + it("retries only router gateway statuses", async () => { + const statuses = [502, 503, 504, 500, 401]; + const server = await listen((_req, res) => { + const status = statuses.shift() ?? 500; + res.writeHead(status); + res.end(); + }); + try { + const relay = httpPaymentRelay({ + baseUrl: origin(server), + apiKey: "test-key", + transactionId: "tx_fixture_shopify", + }); + for (const status of [502, 503, 504, 500, 401]) { + const error = await relay + .relay(REQUEST) + .catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(RelayHttpError); + expect((error as RelayHttpError).status).toBe(status); + expect(isRetryableRelayError(error)).toBe( + status === 502 || status === 503 || status === 504, + ); + } + } finally { + await new Promise((resolve) => server.close(resolve)); + } + }); }); function stalledBody( diff --git a/packages/browser-pay/src/relay.ts b/packages/browser-pay/src/relay.ts index 697aa6c75..1cd25d52b 100644 --- a/packages/browser-pay/src/relay.ts +++ b/packages/browser-pay/src/relay.ts @@ -33,6 +33,30 @@ export function isRelayTimeout(error: unknown): boolean { return error instanceof RelayTimeoutError || isAbortError(error); } +const ROUTER_STATUS = new Set([502, 503, 504]); + +export class RelayHttpError extends Error { + readonly status: number; + constructor(status: number) { + super(`Payment relay failed (HTTP ${status})`); + this.name = "RelayHttpError"; + this.status = status; + } +} + +export class RelayTransportError extends Error { + constructor() { + super("Payment relay failed"); + this.name = "RelayTransportError"; + } +} + +export function isRetryableRelayError(error: unknown): boolean { + if (isRelayTimeout(error)) return true; + if (error instanceof RelayTransportError) return true; + return error instanceof RelayHttpError && ROUTER_STATUS.has(error.status); +} + function isAbortError(error: unknown): boolean { return ( typeof error === "object" && @@ -104,7 +128,7 @@ function requireHttpOptions(options: HttpRelayOptions): { function transportError(error: unknown, signal?: AbortSignal): Error { if (error instanceof RelayTimeoutError) return error; if (signal?.aborted || isAbortError(error)) return new RelayTimeoutError(); - return new Error("Payment relay failed"); + return new RelayTransportError(); } async function discardBody(response: Response): Promise { @@ -137,7 +161,7 @@ export function httpPaymentRelay(options: HttpRelayOptions): PaymentRelay { if (response.status >= 300 && response.status < 400) { throw new Error("Payment relay refused a redirect"); } - throw new Error(`Payment relay failed (HTTP ${response.status})`); + throw new RelayHttpError(response.status); } let text: string; try { From 59046bab0b2d6391b7915562b364e585cb1425b3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Oct 2026 03:10:05 +0000 Subject: [PATCH 06/13] fix(browser-pay): share the relay_in_progress poll across identical pauses One flight now owns the post and the backoff, so a second pause cannot repost a key that another waiter already saw as unknown. Detach aborts that backoff. Co-authored-by: Rimmely Ewan --- packages/browser-pay/README.md | 2 +- .../src/interceptor.relay-in-progress.test.ts | 232 +++++++++++++++++- packages/browser-pay/src/interceptor.ts | 135 +++++----- 3 files changed, 299 insertions(+), 70 deletions(-) diff --git a/packages/browser-pay/README.md b/packages/browser-pay/README.md index 4318d0ca5..8038af59d 100644 --- a/packages/browser-pay/README.md +++ b/packages/browser-pay/README.md @@ -87,7 +87,7 @@ A request is relayed when the held placeholder number appears in the URL or body A relayed response fails the paused request only when that response contains the held placeholder number. -Identical in-flight retries (same method, URL, and body) share one relay call. `AttachOptions.timeoutMs` is the deadline for a terminal outcome. It defaults to 20 seconds. While the relay answers `relay_in_progress`, the same key is posted again after a bounded backoff. At the deadline the paused request fails with `timeout`. A relay that ignores abort still fails when the deadline passes, and one waiter's timeout does not abort a later waiter of the same flight. A terminal refusal is logged once. A transport failure drops the key so a later retry can resend. `outcome: "unknown"` does not. +Identical in-flight retries (same method, URL, and body) share one relay poll, including the backoff between `relay_in_progress` answers. `AttachOptions.timeoutMs` is the deadline for a terminal outcome. It defaults to 20 seconds. While the relay answers `relay_in_progress`, the same key is posted again after a bounded backoff. At the deadline the paused request fails with `timeout`. A relay that ignores abort still fails when the deadline passes, and one waiter's timeout does not abort a later waiter of the same flight. A terminal refusal is logged once. A transport failure drops the key so a later retry can resend. `outcome: "unknown"` does not. Captcha widgets, extension pages, and workers are not paused. A blank iframe, including `about:blank`, stays paused so a navigation onto the card URL cannot slip through. A placeholder that shows up on any other page or iframe is relayed. diff --git a/packages/browser-pay/src/interceptor.relay-in-progress.test.ts b/packages/browser-pay/src/interceptor.relay-in-progress.test.ts index 7bf642923..cd361ba88 100644 --- a/packages/browser-pay/src/interceptor.relay-in-progress.test.ts +++ b/packages/browser-pay/src/interceptor.relay-in-progress.test.ts @@ -53,6 +53,7 @@ interface SeenPost { method: string; url: string; idempotencyKey: string; + requestBody: string; } function serverFrame(text: string): Buffer { @@ -228,6 +229,7 @@ function startRelay(script: RelayResponse[] = [IN_PROGRESS, RELAYED]): Promise<{ method: req.method ?? "", url: req.url ?? "", idempotencyKey: body.idempotencyKey, + requestBody: body.request.body, }); const payload = script[Math.min(posts.length, script.length) - 1]; res.writeHead(200, { "content-type": "application/json" }); @@ -322,8 +324,18 @@ describe("relay_in_progress", () => { }, }).toEqual({ relayPosts: [ - { method: "POST", url: RELAY_PATH, idempotencyKey }, - { method: "POST", url: RELAY_PATH, idempotencyKey }, + { + method: "POST", + url: RELAY_PATH, + idempotencyKey, + requestBody: CARD, + }, + { + method: "POST", + url: RELAY_PATH, + idempotencyKey, + requestBody: CARD, + }, ], fetch: { failRequest: 0, @@ -331,6 +343,13 @@ describe("relay_in_progress", () => { continueRequest: 0, }, }); + const fulfilled = cdp.commands.find( + (command) => command.method === "Fetch.fulfillRequest", + ); + expect(fulfilled?.params.responseCode).toBe(201); + expect(fulfilled?.params.body).toBe( + Buffer.from(RELAYED.response.body, "utf8").toString("base64"), + ); } finally { await handle.detach(); await cdp.close(); @@ -389,6 +408,7 @@ describe("relay_in_progress", () => { "timeout", ); expect(relay.posts.length).toBeGreaterThanOrEqual(2); + expect(relay.posts.length).toBeLessThanOrEqual(6); expect( relay.posts.every((post) => post.idempotencyKey === idempotencyKey), ).toBe(true); @@ -457,8 +477,18 @@ describe("relay_in_progress", () => { "refused", ); expect(relay.posts).toEqual([ - { method: "POST", url: RELAY_PATH, idempotencyKey }, - { method: "POST", url: RELAY_PATH, idempotencyKey }, + { + method: "POST", + url: RELAY_PATH, + idempotencyKey, + requestBody: CARD, + }, + { + method: "POST", + url: RELAY_PATH, + idempotencyKey, + requestBody: CARD, + }, ]); expect(count(cdp.commands, "Fetch.failRequest")).toBe(1); expect(count(cdp.commands, "Fetch.fulfillRequest")).toBe(0); @@ -539,8 +569,18 @@ describe("relay_in_progress", () => { "second fail", ); expect(relay.posts).toEqual([ - { method: "POST", url: RELAY_PATH, idempotencyKey }, - { method: "POST", url: RELAY_PATH, idempotencyKey }, + { + method: "POST", + url: RELAY_PATH, + idempotencyKey, + requestBody: CARD, + }, + { + method: "POST", + url: RELAY_PATH, + idempotencyKey, + requestBody: CARD, + }, ]); expect(count(cdp.commands, "Fetch.fulfillRequest")).toBe(0); expect(events).toContainEqual( @@ -552,4 +592,184 @@ describe("relay_in_progress", () => { await relay.close(); } }); + + it("shares one poll when a second pause arrives during backoff", async () => { + const idempotencyKey = paymentIdempotencyKey("POST", CHECKOUT_URL, CARD); + const cdp = await startCdp(); + const relay = await startRelay(); + const handle = await attachInterceptor(cdp.url, { + relay: httpPaymentRelay({ + baseUrl: relay.origin, + apiKey: "test-key", + transactionId: TRANSACTION_ID, + }), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + try { + armPage(cdp); + await waitFor( + () => cdp.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + emitPaused(cdp, "req-first"); + await waitFor(() => relay.posts.length === 1, "first post"); + emitPaused(cdp, "req-second"); + await waitFor( + () => count(cdp.commands, "Fetch.fulfillRequest") === 2, + "both fulfilled", + ); + expect(relay.posts).toEqual([ + { + method: "POST", + url: RELAY_PATH, + idempotencyKey, + requestBody: CARD, + }, + { + method: "POST", + url: RELAY_PATH, + idempotencyKey, + requestBody: CARD, + }, + ]); + expect(count(cdp.commands, "Fetch.failRequest")).toBe(0); + } finally { + await handle.detach(); + await cdp.close(); + await relay.close(); + } + }); + + it("does not post a key again after an overlapping pause sees unknown", async () => { + const idempotencyKey = paymentIdempotencyKey("POST", CHECKOUT_URL, CARD); + const cdp = await startCdp(); + const relay = await startRelay([IN_PROGRESS, { outcome: "unknown" }]); + const events: InterceptorEvent[] = []; + const handle = await attachInterceptor(cdp.url, { + relay: httpPaymentRelay({ + baseUrl: relay.origin, + apiKey: "test-key", + transactionId: TRANSACTION_ID, + }), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + handle.on((event) => events.push(event)); + try { + armPage(cdp); + await waitFor( + () => cdp.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + emitPaused(cdp, "req-unknown-a"); + await waitFor(() => relay.posts.length === 1, "first post"); + emitPaused(cdp, "req-unknown-b"); + await waitFor( + () => + events.filter( + (event) => event.type === "failed" && event.reason === "unknown", + ).length === 2, + "both unknown", + ); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(relay.posts).toEqual([ + { + method: "POST", + url: RELAY_PATH, + idempotencyKey, + requestBody: CARD, + }, + { + method: "POST", + url: RELAY_PATH, + idempotencyKey, + requestBody: CARD, + }, + ]); + expect(count(cdp.commands, "Fetch.failRequest")).toBe(2); + expect(count(cdp.commands, "Fetch.fulfillRequest")).toBe(0); + } finally { + await handle.detach(); + await cdp.close(); + await relay.close(); + } + }); + + it("stops polling when detach happens during backoff", async () => { + const cdp = await startCdp(); + const relay = await startRelay([IN_PROGRESS]); + const events: InterceptorEvent[] = []; + const handle = await attachInterceptor(cdp.url, { + relay: httpPaymentRelay({ + baseUrl: relay.origin, + apiKey: "test-key", + transactionId: TRANSACTION_ID, + }), + placeholder: HELD, + profileId: "shopify-card-vault", + }); + handle.on((event) => events.push(event)); + try { + armPage(cdp); + await waitFor( + () => cdp.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + emitPaused(cdp, "req-detach"); + await waitFor(() => relay.posts.length === 1, "first post"); + const started = Date.now(); + await handle.detach(); + await waitFor( + () => + events.some( + (event) => event.type === "failed" && event.reason === "detached", + ), + "detached", + ); + expect(Date.now() - started).toBeLessThan(150); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(relay.posts).toHaveLength(1); + } finally { + await handle.detach(); + await cdp.close(); + await relay.close(); + } + }); }); + +function armPage(cdp: { + emit: (event: Record) => void; +}): void { + cdp.emit({ + method: "Target.attachedToTarget", + params: { + sessionId: "page-1", + waitingForDebugger: true, + targetInfo: { + targetId: "target-page", + type: "page", + url: "https://shop.example/checkout", + }, + }, + }); +} + +function emitPaused( + cdp: { emit: (event: Record) => void }, + requestId: string, +): void { + cdp.emit({ + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId, + request: { + url: CHECKOUT_URL, + method: "POST", + headers: { "content-type": "application/json" }, + postData: CARD, + }, + }, + }); +} diff --git a/packages/browser-pay/src/interceptor.ts b/packages/browser-pay/src/interceptor.ts index bd7c2a67f..6387bbf19 100644 --- a/packages/browser-pay/src/interceptor.ts +++ b/packages/browser-pay/src/interceptor.ts @@ -26,10 +26,11 @@ export interface AttachOptions { /** * How long to wait for a terminal relay outcome before failing the paused * request. `relay_in_progress` is posted again with the same idempotency - * key until this deadline. Any other refusal fails the request. - * `outcome: "unknown"` is not sent again. A relay that ignores the abort - * signal still fails here. One waiter's timeout does not abort a shared - * flight that another waiter still holds. + * key until this deadline. Identical pauses share that poll, including the + * backoff. Any other refusal fails the request. `outcome: "unknown"` is + * not sent again. A relay that ignores the abort signal still fails here. + * One waiter's timeout does not abort a shared flight that another waiter + * still holds. */ timeoutMs?: number; /** Defaults to a logger that discards every line. */ @@ -536,27 +537,56 @@ class PaymentInterceptor { pageOrigin: string, idempotencyKey: string, ): Promise { - const deadline = Date.now() + this.timeoutMs; + const flight = this.relayOnce(idempotencyKey, (signal) => + this.pollRelay(request, body, pageOrigin, idempotencyKey, signal), + ); + try { + const response = await failAfter(flight.promise, this.timeoutMs); + this.releaseWaiter(flight); + return this.decisionForOutcome(response, idempotencyKey); + } catch (error) { + this.releaseWaiter(flight); + return { + action: "fail", + reason: this.closed ? "detached" : failureReason(error), + }; + } + } + + private async pollRelay( + request: PausedRequest, + body: string, + pageOrigin: string, + idempotencyKey: string, + signal: AbortSignal, + ): Promise { let poll = 0; for (;;) { - if (this.closed) return { action: "fail", reason: "detached" }; - const remaining = deadline - Date.now(); - if (remaining <= 0) return { action: "fail", reason: "timeout" }; - const relayed = await this.callRelay( - request, - body, - pageOrigin, - idempotencyKey, - remaining, + if (signal.aborted || this.closed) throw abortError(signal); + if (this.unknownKeys.has(idempotencyKey)) { + return { outcome: "unknown" }; + } + const response = await this.options.relay.relay( + { + injectionRef: this.options.placeholder.injectionRef, + profileId: this.options.profileId, + pageOrigin, + request: { + method: request.method, + url: request.url, + headers: forwardedHeaders(request.headers), + body, + }, + idempotencyKey, + }, + { signal }, ); - if (relayed.action === "fail") return relayed; - if (!isRelayInProgress(relayed.response)) { - return this.decisionForOutcome(relayed.response, idempotencyKey); + if (response.outcome === "unknown") { + this.unknownKeys.add(idempotencyKey); } + if (!isRelayInProgress(response)) return response; poll += 1; - const waitMs = Math.min(relayPollDelayMs(poll), deadline - Date.now()); - if (waitMs <= 0) return { action: "fail", reason: "timeout" }; - await sleep(waitMs); + await sleep(relayPollDelayMs(poll), signal); } } @@ -577,46 +607,6 @@ class PaymentInterceptor { return { kind: "miss" }; } - private async callRelay( - request: PausedRequest, - body: string, - pageOrigin: string, - idempotencyKey: string, - budgetMs: number, - ): Promise< - | { action: "ok"; response: RelayResponse } - | { action: "fail"; reason: FailureReason } - > { - const flight = this.relayOnce(idempotencyKey, (signal) => - this.options.relay.relay( - { - injectionRef: this.options.placeholder.injectionRef, - profileId: this.options.profileId, - pageOrigin, - request: { - method: request.method, - url: request.url, - headers: forwardedHeaders(request.headers), - body, - }, - idempotencyKey, - }, - { signal }, - ), - ); - try { - const response = await failAfter(flight.promise, budgetMs); - this.releaseWaiter(flight); - return { action: "ok", response }; - } catch (error) { - this.releaseWaiter(flight); - return { - action: "fail", - reason: this.closed ? "detached" : failureReason(error), - }; - } - } - private decisionForOutcome( response: RelayResponse, idempotencyKey: string, @@ -823,9 +813,28 @@ function relayPollDelayMs(attempt: number): number { return Math.min(RELAY_POLL_INITIAL_MS * 2 ** shift, RELAY_POLL_MAX_MS); } -function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); +function abortError(signal: AbortSignal): Error { + if (signal.reason instanceof Error) return signal.reason; + const error = new Error("The operation was aborted"); + error.name = "AbortError"; + return error; +} + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(abortError(signal)); + return; + } + const onAbort = () => { + clearTimeout(timer); + reject(abortError(signal)); + }; + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal.addEventListener("abort", onAbort, { once: true }); }); } From 93a5600b4a8268a76f71693f3cda6b32e1baaeed Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Oct 2026 03:14:29 +0000 Subject: [PATCH 07/13] fix(browser-pay): cap each shared relay attempt and reject bad deadlines The attempt timer now aborts the flight's POST, so two pauses share one retry. timeoutMs and attemptTimeoutMs must be finite and positive. Co-authored-by: Rimmely Ewan --- .../src/interceptor.protocol.test.ts | 1 + .../src/interceptor.relay-in-progress.test.ts | 66 +++++++++++++++++++ packages/browser-pay/src/interceptor.ts | 9 +++ 3 files changed, 76 insertions(+) diff --git a/packages/browser-pay/src/interceptor.protocol.test.ts b/packages/browser-pay/src/interceptor.protocol.test.ts index 9b4291787..7187b7dda 100644 --- a/packages/browser-pay/src/interceptor.protocol.test.ts +++ b/packages/browser-pay/src/interceptor.protocol.test.ts @@ -1345,6 +1345,7 @@ describe("interceptor CDP decisions", () => { placeholder: HELD, profileId: "shopify-card-vault", timeoutMs: 400, + attemptTimeoutMs: 1_000, }); handle.on((event) => events.push(event)); attachPage(stub.emit); diff --git a/packages/browser-pay/src/interceptor.relay-in-progress.test.ts b/packages/browser-pay/src/interceptor.relay-in-progress.test.ts index d789f2a16..12c042d1a 100644 --- a/packages/browser-pay/src/interceptor.relay-in-progress.test.ts +++ b/packages/browser-pay/src/interceptor.relay-in-progress.test.ts @@ -987,6 +987,72 @@ describe("relay retry before the deadline", () => { await relay.close(); } }); + + it("aborts one shared attempt and fulfills both pauses", async () => { + const cdp = await startCdp(); + let calls = 0; + const aborts: number[] = []; + const started = Date.now(); + const handle = await attachInterceptor(cdp.url, { + relay: { + relay(_request, options) { + calls += 1; + if (calls === 1) { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener("abort", () => { + aborts.push(Date.now() - started); + const reason = options.signal?.reason; + reject(reason instanceof Error ? reason : new Error("aborted")); + }); + }); + } + return Promise.resolve(RELAYED); + }, + }, + placeholder: HELD, + profileId: "shopify-card-vault", + timeoutMs: 2_000, + attemptTimeoutMs: 120, + }); + try { + armPage(cdp); + await waitFor( + () => cdp.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + emitPaused(cdp, "req-shared-a"); + await waitFor(() => calls === 1, "first attempt"); + await new Promise((resolve) => setTimeout(resolve, 40)); + emitPaused(cdp, "req-shared-b"); + await waitFor( + () => count(cdp.commands, "Fetch.fulfillRequest") === 2, + "both fulfilled", + ); + expect(calls).toBe(2); + expect(aborts).toHaveLength(1); + expect(aborts[0]).toBeGreaterThanOrEqual(100); + expect(aborts[0]).toBeLessThan(400); + expect(count(cdp.commands, "Fetch.failRequest")).toBe(0); + } finally { + await handle.detach(); + await cdp.close(); + } + }); + + it("rejects a timeout that is not a finite positive number", async () => { + const relay = { relay: async () => RELAYED }; + const base = { + relay, + placeholder: HELD, + profileId: "shopify-card-vault", + }; + await expect( + attachInterceptor("ws://127.0.0.1:9", { ...base, timeoutMs: Number.NaN }), + ).rejects.toThrow(/timeoutMs/); + await expect( + attachInterceptor("ws://127.0.0.1:9", { ...base, attemptTimeoutMs: 0 }), + ).rejects.toThrow(/attemptTimeoutMs/); + }); }); function armPage(cdp: { diff --git a/packages/browser-pay/src/interceptor.ts b/packages/browser-pay/src/interceptor.ts index 4381b7eba..5a30a0ee8 100644 --- a/packages/browser-pay/src/interceptor.ts +++ b/packages/browser-pay/src/interceptor.ts @@ -1302,10 +1302,19 @@ function readAttachedSessionId(value: unknown): string | undefined { return value.sessionId.length > 0 ? value.sessionId : undefined; } +function assertPositiveDuration(name: string, value: number | undefined): void { + if (value === undefined) return; + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`${name} must be a finite positive number`); + } +} + function assertAttachOptions(options: AttachOptions): void { if (typeof options.relay?.relay !== "function") { throw new Error("A payment relay is required"); } + assertPositiveDuration("timeoutMs", options.timeoutMs); + assertPositiveDuration("attemptTimeoutMs", options.attemptTimeoutMs); if (!options.profileId) { throw new Error("profileId is required"); } From bda1954ca8b4ae9b2652c90f579404c2a57ed3bc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Oct 2026 03:54:02 +0000 Subject: [PATCH 08/13] fix(browser-pay): treat a relay that resolves after detach as detached The outcome decision sits outside the wait, so a throwing logger cannot release the shared flight twice. A deadline failure also stops further posts. Co-authored-by: Rimmely Ewan --- .../src/interceptor.relay-in-progress.test.ts | 47 +++++++++++++++++++ packages/browser-pay/src/interceptor.ts | 8 ++-- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/packages/browser-pay/src/interceptor.relay-in-progress.test.ts b/packages/browser-pay/src/interceptor.relay-in-progress.test.ts index cd361ba88..1f67727a6 100644 --- a/packages/browser-pay/src/interceptor.relay-in-progress.test.ts +++ b/packages/browser-pay/src/interceptor.relay-in-progress.test.ts @@ -417,6 +417,9 @@ describe("relay_in_progress", () => { expect(events).toContainEqual( expect.objectContaining({ type: "failed", reason: "timeout" }), ); + const postsAtFailure = relay.posts.length; + await new Promise((resolve) => setTimeout(resolve, 2_500)); + expect(relay.posts).toHaveLength(postsAtFailure); } finally { await handle.detach(); await cdp.close(); @@ -736,6 +739,50 @@ describe("relay_in_progress", () => { await relay.close(); } }); + + it("reports detached when the relay resolves after detach", async () => { + const cdp = await startCdp(); + const events: InterceptorEvent[] = []; + let release: (response: RelayResponse) => void = () => {}; + const pending = new Promise((resolve) => { + release = resolve; + }); + const handle = await attachInterceptor(cdp.url, { + relay: { relay: () => pending }, + placeholder: HELD, + profileId: "shopify-card-vault", + timeoutMs: 5_000, + }); + handle.on((event) => events.push(event)); + try { + armPage(cdp); + await waitFor( + () => cdp.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + emitPaused(cdp, "req-detach-resolve"); + await waitFor( + () => events.some((event) => event.type === "paused"), + "paused", + ); + const detaching = handle.detach(); + release(RELAYED); + await detaching; + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(events).toContainEqual( + expect.objectContaining({ type: "failed", reason: "detached" }), + ); + expect( + events.some( + (event) => + event.type === "relayed" || event.type === "relayed_unfulfilled", + ), + ).toBe(false); + } finally { + await handle.detach(); + await cdp.close(); + } + }); }); function armPage(cdp: { diff --git a/packages/browser-pay/src/interceptor.ts b/packages/browser-pay/src/interceptor.ts index 6387bbf19..c92d7bc1c 100644 --- a/packages/browser-pay/src/interceptor.ts +++ b/packages/browser-pay/src/interceptor.ts @@ -540,10 +540,9 @@ class PaymentInterceptor { const flight = this.relayOnce(idempotencyKey, (signal) => this.pollRelay(request, body, pageOrigin, idempotencyKey, signal), ); + let response: RelayResponse; try { - const response = await failAfter(flight.promise, this.timeoutMs); - this.releaseWaiter(flight); - return this.decisionForOutcome(response, idempotencyKey); + response = await failAfter(flight.promise, this.timeoutMs); } catch (error) { this.releaseWaiter(flight); return { @@ -551,6 +550,9 @@ class PaymentInterceptor { reason: this.closed ? "detached" : failureReason(error), }; } + this.releaseWaiter(flight); + if (this.closed) return { action: "fail", reason: "detached" }; + return this.decisionForOutcome(response, idempotencyKey); } private async pollRelay( From 6e3733a40e397dd171614a76b057dc7b770ae766 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Oct 2026 03:54:55 +0000 Subject: [PATCH 09/13] fix(browser-pay): retry an attempt abort even when the relay rejects plainly The attempt timer is cleared when the flight aborts, including a relay that never settles. Durations that do not fit in a timer are rejected. Co-authored-by: Rimmely Ewan --- .../src/interceptor.relay-in-progress.test.ts | 15 +++--- packages/browser-pay/src/interceptor.ts | 46 +++++++++++-------- 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/packages/browser-pay/src/interceptor.relay-in-progress.test.ts b/packages/browser-pay/src/interceptor.relay-in-progress.test.ts index 3731e21c6..72673260b 100644 --- a/packages/browser-pay/src/interceptor.relay-in-progress.test.ts +++ b/packages/browser-pay/src/interceptor.relay-in-progress.test.ts @@ -1038,18 +1038,18 @@ describe("relay retry before the deadline", () => { it("aborts one shared attempt and fulfills both pauses", async () => { const cdp = await startCdp(); let calls = 0; + let attemptStarted = 0; const aborts: number[] = []; - const started = Date.now(); const handle = await attachInterceptor(cdp.url, { relay: { relay(_request, options) { calls += 1; if (calls === 1) { + attemptStarted = Date.now(); return new Promise((_resolve, reject) => { options?.signal?.addEventListener("abort", () => { - aborts.push(Date.now() - started); - const reason = options.signal?.reason; - reject(reason instanceof Error ? reason : new Error("aborted")); + aborts.push(Date.now() - attemptStarted); + reject(new Error("aborted")); }); }); } @@ -1069,7 +1069,7 @@ describe("relay retry before the deadline", () => { ); emitPaused(cdp, "req-shared-a"); await waitFor(() => calls === 1, "first attempt"); - await new Promise((resolve) => setTimeout(resolve, 40)); + await new Promise((resolve) => setTimeout(resolve, 80)); emitPaused(cdp, "req-shared-b"); await waitFor( () => count(cdp.commands, "Fetch.fulfillRequest") === 2, @@ -1078,7 +1078,7 @@ describe("relay retry before the deadline", () => { expect(calls).toBe(2); expect(aborts).toHaveLength(1); expect(aborts[0]).toBeGreaterThanOrEqual(100); - expect(aborts[0]).toBeLessThan(400); + expect(aborts[0]).toBeLessThan(160); expect(count(cdp.commands, "Fetch.failRequest")).toBe(0); } finally { await handle.detach(); @@ -1099,6 +1099,9 @@ describe("relay retry before the deadline", () => { await expect( attachInterceptor("ws://127.0.0.1:9", { ...base, attemptTimeoutMs: 0 }), ).rejects.toThrow(/attemptTimeoutMs/); + await expect( + attachInterceptor("ws://127.0.0.1:9", { ...base, timeoutMs: 2 ** 31 }), + ).rejects.toThrow(/timeoutMs/); }); }); diff --git a/packages/browser-pay/src/interceptor.ts b/packages/browser-pay/src/interceptor.ts index 3c127ec6a..c15d16918 100644 --- a/packages/browser-pay/src/interceptor.ts +++ b/packages/browser-pay/src/interceptor.ts @@ -602,7 +602,7 @@ class PaymentInterceptor { } } - private postAttempt( + private async postAttempt( request: PausedRequest, body: string, pageOrigin: string, @@ -610,15 +610,19 @@ class PaymentInterceptor { signal: AbortSignal, ): Promise { const attempt = new AbortController(); - const onAbort = () => attempt.abort(signal.reason); - if (signal.aborted) attempt.abort(signal.reason); - else signal.addEventListener("abort", onAbort, { once: true }); - const timer = setTimeout( - () => attempt.abort(new RelayTimeoutError()), - this.attemptTimeoutMs, - ); - return this.options.relay - .relay( + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + attempt.abort(new RelayTimeoutError()); + }, this.attemptTimeoutMs); + const onAbort = () => { + clearTimeout(timer); + attempt.abort(signal.reason); + }; + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort); + try { + return await this.options.relay.relay( { injectionRef: this.options.placeholder.injectionRef, profileId: this.options.profileId, @@ -632,11 +636,14 @@ class PaymentInterceptor { idempotencyKey, }, { signal: attempt.signal }, - ) - .finally(() => { - clearTimeout(timer); - signal.removeEventListener("abort", onAbort); - }); + ); + } catch (error) { + if (timedOut && !signal.aborted) throw new RelayTimeoutError(); + throw error; + } finally { + clearTimeout(timer); + signal.removeEventListener("abort", onAbort); + } } private async readHeld( @@ -783,11 +790,10 @@ class PaymentInterceptor { return flight; } - private releaseWaiter(flight: Flight): boolean { + private releaseWaiter(flight: Flight): void { flight.waiters -= 1; - if (flight.waiters > 0) return false; + if (flight.waiters > 0) return; flight.controller.abort(); - return true; } private async detach(): Promise { @@ -1304,9 +1310,11 @@ function readAttachedSessionId(value: unknown): string | undefined { return value.sessionId.length > 0 ? value.sessionId : undefined; } +const MAX_TIMER_MS = 2_147_483_647; + function assertPositiveDuration(name: string, value: number | undefined): void { if (value === undefined) return; - if (!Number.isFinite(value) || value <= 0) { + if (!Number.isFinite(value) || value <= 0 || value > MAX_TIMER_MS) { throw new Error(`${name} must be a finite positive number`); } } From 703312e77f00c7febca5dd2d6bf81694687f96d6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Oct 2026 04:02:01 +0000 Subject: [PATCH 10/13] ci(browser-pay): rerun checks after an unrelated opencode timeout Co-authored-by: Rimmely Ewan From 1fcae124ce406805e062d898b1a93a4ad6345d7a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Oct 2026 04:15:31 +0000 Subject: [PATCH 11/13] test(browser-pay): cover a later pause after the first deadline Drop the detach timing bound. A second pause still fulfills when the first waiter times out during the shared poll. Co-authored-by: Rimmely Ewan --- .../src/interceptor.relay-in-progress.test.ts | 57 ++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/packages/browser-pay/src/interceptor.relay-in-progress.test.ts b/packages/browser-pay/src/interceptor.relay-in-progress.test.ts index 1f67727a6..37ba1d714 100644 --- a/packages/browser-pay/src/interceptor.relay-in-progress.test.ts +++ b/packages/browser-pay/src/interceptor.relay-in-progress.test.ts @@ -721,7 +721,6 @@ describe("relay_in_progress", () => { ); emitPaused(cdp, "req-detach"); await waitFor(() => relay.posts.length === 1, "first post"); - const started = Date.now(); await handle.detach(); await waitFor( () => @@ -730,7 +729,6 @@ describe("relay_in_progress", () => { ), "detached", ); - expect(Date.now() - started).toBeLessThan(150); await new Promise((resolve) => setTimeout(resolve, 300)); expect(relay.posts).toHaveLength(1); } finally { @@ -783,6 +781,61 @@ describe("relay_in_progress", () => { await cdp.close(); } }); + + it("lets a later pause finish after the first waiter times out", async () => { + const cdp = await startCdp(); + const events: InterceptorEvent[] = []; + let calls = 0; + let releaseSecond: (response: RelayResponse) => void = () => {}; + const handle = await attachInterceptor(cdp.url, { + relay: { + relay(_request, options) { + calls += 1; + if (calls === 1) return Promise.resolve(IN_PROGRESS); + return new Promise((resolve, reject) => { + releaseSecond = resolve; + options?.signal?.addEventListener("abort", () => { + reject(new Error("aborted")); + }); + }); + }, + }, + placeholder: HELD, + profileId: "shopify-card-vault", + timeoutMs: 500, + }); + handle.on((event) => events.push(event)); + try { + armPage(cdp); + await waitFor( + () => cdp.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + emitPaused(cdp, "req-early-deadline"); + await waitFor(() => calls === 1, "first post"); + await new Promise((resolve) => setTimeout(resolve, 40)); + emitPaused(cdp, "req-later-deadline"); + await waitFor( + () => + events.some( + (event) => event.type === "failed" && event.reason === "timeout", + ), + "early timeout", + ); + expect(calls).toBe(2); + releaseSecond(RELAYED); + await waitFor( + () => count(cdp.commands, "Fetch.fulfillRequest") === 1, + "later fulfill", + ); + expect(count(cdp.commands, "Fetch.failRequest")).toBe(1); + expect(events.filter((event) => event.type === "failed")).toHaveLength(1); + } finally { + releaseSecond(RELAYED); + await handle.detach(); + await cdp.close(); + } + }); }); function armPage(cdp: { From 1b545cb76fa778e353cfc492ac74bb77ee0f328f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Oct 2026 04:15:40 +0000 Subject: [PATCH 12/13] docs(browser-pay): say non-2xx statuses fail the paused request Co-authored-by: Rimmely Ewan --- packages/browser-pay/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/browser-pay/README.md b/packages/browser-pay/README.md index bfdf00f3d..e805e4df9 100644 --- a/packages/browser-pay/README.md +++ b/packages/browser-pay/README.md @@ -63,7 +63,7 @@ No answer after the send: The injection is consumed. The paused request fails and the same body is not sent again. -A redirect, an HTTP status other than 502, 503, or 504, or an unreadable body fails the paused browser request. David's relay returns HTTP 200 for every outcome, so a 401 or 500 is not read as `unknown` or `refused`. It is not fulfilled with the error JSON. A transport error, a per-attempt timeout, and a router HTTP 502, 503, or 504 are posted again with the same idempotency key. If fulfill fails, the paused request is failed and the agent event is `relayed_unfulfilled`. +A redirect, a non-2xx HTTP status other than 502, 503, or 504, or an unreadable body fails the paused browser request. David's relay returns HTTP 200 for every outcome, so a 401 or 500 is not read as `unknown` or `refused`. It is not fulfilled with the error JSON. A transport error, a per-attempt timeout, and a router HTTP 502, 503, or 504 are posted again with the same idempotency key. If fulfill fails, the paused request is failed and the agent event is `relayed_unfulfilled`. ## What an agent does From 4939cfe547db420f22dd763fbfb4f8c36438e5b4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Oct 2026 04:36:59 +0000 Subject: [PATCH 13/13] test(browser-pay): relaunch Chrome when DevToolsActivePort never appears The integration suite failed on CI while Chrome was still alive and the port file was missing. A second fresh profile covers that startup, and the error keeps Chrome's stderr if both attempts miss. Co-authored-by: Rimmely Ewan --- .../src/interceptor.integration.test.ts | 120 +++++++++++++----- 1 file changed, 88 insertions(+), 32 deletions(-) diff --git a/packages/browser-pay/src/interceptor.integration.test.ts b/packages/browser-pay/src/interceptor.integration.test.ts index 593394efc..3681c5a33 100644 --- a/packages/browser-pay/src/interceptor.integration.test.ts +++ b/packages/browser-pay/src/interceptor.integration.test.ts @@ -138,7 +138,7 @@ async function waitForDebugger( userDataDir: string, proc: ChildProcess, ): Promise { - const deadline = Date.now() + 15_000; + const deadline = Date.now() + 20_000; while (Date.now() < deadline) { if (proc.exitCode !== null || proc.signalCode !== null) { throw new Error( @@ -152,6 +152,87 @@ async function waitForDebugger( throw new Error("CDP endpoint did not come up (DevToolsActivePort missing)"); } +function chromeArgs(userDataDir: string): string[] { + return [ + "--headless=new", + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + "--site-per-process", + "--remote-debugging-port=0", + "--remote-allow-origins=*", + `--user-data-dir=${userDataDir}`, + "--no-first-run", + "--no-default-browser-check", + "about:blank", + ]; +} + +async function killChrome( + proc: ChildProcess | undefined, + exited: boolean, +): Promise { + if ( + !proc?.pid || + exited || + proc.exitCode !== null || + proc.signalCode !== null + ) { + return; + } + await new Promise((resolve) => { + const timer = setTimeout(resolve, 2_000); + proc.once("exit", () => { + clearTimeout(timer); + resolve(); + }); + try { + process.kill(-proc.pid!, "SIGKILL"); + } catch { + proc.kill("SIGKILL"); + } + }); +} + +/** + * GitHub runners sometimes leave Chrome alive without writing + * DevToolsActivePort. One fresh profile is enough when that happens. + * stderr is kept so a second miss says why. + */ +async function launchChrome(bin: string): Promise<{ + proc: ChildProcess; + userDataDir: string; + endpoint: string; +}> { + let lastError: Error | undefined; + for (let attempt = 0; attempt < 2; attempt += 1) { + const userDataDir = await mkdtemp(path.join(os.tmpdir(), "browser-pay-")); + const stderr: string[] = []; + const proc = spawn(bin, chromeArgs(userDataDir), { + detached: true, + stdio: ["ignore", "ignore", "pipe"], + }); + proc.stderr?.on("data", (chunk: Buffer | string) => { + stderr.push(String(chunk)); + if (stderr.length > 40) stderr.shift(); + }); + try { + const endpoint = await waitForDebugger(userDataDir, proc); + return { proc, userDataDir, endpoint }; + } catch (error) { + const detail = error instanceof Error ? error.message : "chrome failed"; + const tail = stderr.join("").trim().slice(-600); + lastError = new Error(tail ? `${detail}\n${tail}` : detail); + await killChrome( + proc, + proc.exitCode !== null || proc.signalCode !== null, + ); + await rm(userDataDir, { recursive: true, force: true }); + } + } + throw lastError ?? new Error("Chrome did not start"); +} + async function readDebuggerEndpoint( userDataDir: string, ): Promise { @@ -187,48 +268,23 @@ if (!chromeBin) { const hits: Hit[] = []; beforeAll(async () => { - userDataDir = await mkdtemp(path.join(os.tmpdir(), "browser-pay-")); - chrome = spawn( - chromeBin ?? "chrome-missing", - [ - "--headless=new", - "--no-sandbox", - "--disable-dev-shm-usage", - "--disable-gpu", - "--site-per-process", - "--remote-debugging-port=0", - "--remote-allow-origins=*", - `--user-data-dir=${userDataDir}`, - "--no-first-run", - "--no-default-browser-check", - "about:blank", - ], - { detached: true, stdio: "ignore" }, - ); + const launched = await launchChrome(chromeBin ?? "chrome-missing"); + chrome = launched.proc; + userDataDir = launched.userDataDir; + cdpUrl = launched.endpoint; chrome.on("exit", () => { chromeExited = true; }); - cdpUrl = await waitForDebugger(userDataDir, chrome); card = await startServer(hits, FRAME_HTML, "127.0.0.1"); merchant = await startServer( hits, `merchant`, "localhost", ); - }, 30_000); + }, 50_000); afterAll(async () => { - if (chrome?.pid && !chromeExited) { - const done = new Promise((resolve) => { - chrome.once("exit", () => resolve()); - }); - try { - if (!chromeExited) process.kill(-chrome.pid, "SIGKILL"); - } catch { - chrome.kill("SIGKILL"); - } - if (!chromeExited) await done; - } + await killChrome(chrome, chromeExited); await new Promise((resolve) => { if (merchant) merchant.server.close(() => resolve(undefined)); else resolve(undefined);