diff --git a/.changeset/spt-retrieval-backoff.md b/.changeset/spt-retrieval-backoff.md new file mode 100644 index 0000000..722bfc4 --- /dev/null +++ b/.changeset/spt-retrieval-backoff.md @@ -0,0 +1,5 @@ +--- +'@stripe/link-cli': patch +--- + +Retry delayed shared payment token retrieval in both interactive and continued MPP payment flows. diff --git a/packages/cli/src/commands/mpp/pay.test.ts b/packages/cli/src/commands/mpp/pay.test.ts index 212912a..9e07821 100644 --- a/packages/cli/src/commands/mpp/pay.test.ts +++ b/packages/cli/src/commands/mpp/pay.test.ts @@ -57,6 +57,7 @@ beforeEach(() => { }); afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -395,6 +396,47 @@ describe('payWithSpt', () => { expect(refreshedResponse.bodyUsed).toBe(true); }); + it('waits for SPT propagation when continuing an approved spend request', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const retrievedAt: number[] = []; + const repository = { + retrieve: vi.fn().mockImplementation(async () => { + retrievedAt.push(Date.now()); + return { + id: 'lsrq_123', + status: 'approved', + credential_type: 'shared_payment_token', + ...(retrievedAt.length === 8 + ? { shared_payment_token: { id: 'spt_test_123' } } + : {}), + }; + }), + } as unknown as ISpendRequestResource; + const fetcher = vi + .fn() + .mockResolvedValueOnce(challengeResponse()) + .mockResolvedValueOnce(new Response('paid')); + vi.stubGlobal('fetch', fetcher); + + const resultPromise = runMppPayWithSpendRequest( + 'https://merchant.example/challenge', + 'lsrq_123', + 'GET', + undefined, + undefined, + repository, + ); + await vi.runAllTimersAsync(); + + await expect(resultPromise).resolves.toMatchObject({ + status: 200, + body: 'paid', + }); + expect(retrievedAt).toEqual([0, 1000, 2000, 3000, 5000, 7000, 9000, 11000]); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + it('rejects an explicit amount that conflicts with the challenge', async () => { const repository = approvedRepository(); const fetcher = vi.fn().mockResolvedValueOnce(challengeResponse()); diff --git a/packages/cli/src/commands/mpp/pay.tsx b/packages/cli/src/commands/mpp/pay.tsx index db07a85..75c45d6 100644 --- a/packages/cli/src/commands/mpp/pay.tsx +++ b/packages/cli/src/commands/mpp/pay.tsx @@ -104,6 +104,10 @@ export interface MppProbe extends MppRequest { response: Response; } +const SPT_RETRIEVAL_DELAYS_MS = [ + 0, 1000, 1000, 1000, 2000, 2000, 2000, 2000, +] as const; + export async function probeMppRequest( initial: MppRequest, fetcher: typeof fetch = fetch, @@ -164,7 +168,7 @@ export async function runMppPayWithSpendRequest( repository: ISpendRequestResource, approvedChallengeHeader?: string, ): Promise { - const spendRequest = await repository.retrieve(spendRequestId, { + let spendRequest = await repository.retrieve(spendRequestId, { include: ['shared_payment_token'], }); @@ -182,8 +186,19 @@ export async function runMppPayWithSpendRequest( `Spend request must be approved (current status: ${spendRequest.status})`, ); } + for (const delayMs of SPT_RETRIEVAL_DELAYS_MS.slice(1)) { + if (spendRequest.shared_payment_token) break; + await new Promise((resolve) => setTimeout(resolve, delayMs)); + const retrieved = await repository.retrieve(spendRequestId, { + include: ['shared_payment_token'], + }); + if (!retrieved) { + throw new Error(`Spend request ${spendRequestId} not found`); + } + spendRequest = retrieved; + } if (!spendRequest.shared_payment_token) { - throw new Error('Spend request does not have a shared payment token'); + throw new Error('Failed to retrieve shared payment token'); } return payWithSpt( @@ -402,14 +417,15 @@ export async function runMppPayFullFlow( // 6. Retrieve with SPT (retry briefly in case of propagation delay) onStep?.('signing'); - let withSpt = await repository.retrieve(spendRequest.id, { - include: ['shared_payment_token'], - }); - for (let i = 0; i < 3 && withSpt && !withSpt.shared_payment_token; i++) { - await new Promise((r) => setTimeout(r, 1000)); + let withSpt = null; + for (const delayMs of SPT_RETRIEVAL_DELAYS_MS) { + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } withSpt = await repository.retrieve(spendRequest.id, { include: ['shared_payment_token'], }); + if (withSpt?.shared_payment_token) break; } if (!withSpt?.shared_payment_token) { throw new Error('Failed to retrieve shared payment token');