@12-apps/payments-frontend 3.21.1 → 3.21.2
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "3.21.
|
|
3
|
+
"version": "3.21.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "Browser half of the vendor-agnostic payments platform: plug-and-play MUI components for the per-provider settings page (credential form from each provider's schema, masked hints, verify/enable) and the checkout page (PIX QR + polling, card tokenization, hosted-checkout redirect), plus the headless hooks and fetch clients they build on. Talks only to the host's payments HTTP surface — never to a provider directly. Microfrontend-ready: no app coupling, host injects theme and auth.",
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import type { Result } from "../../result";
|
|
2
|
+
import { TERMINAL_STATUSES, type OrderStatus } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ONE self-scheduling wait for a payment to settle, with no React in it.
|
|
6
|
+
*
|
|
7
|
+
* Split out of `use-payment-polling.ts` so the hook there is what it reads as:
|
|
8
|
+
* an effect that owns a loop for the length of an order id. Everything here is
|
|
9
|
+
* the loop itself — the backoff, the wall clock, the per-ask timeout and the
|
|
10
|
+
* attempt counter that lets a hung request be abandoned rather than waited on.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface PollingOptions {
|
|
14
|
+
/** Delay between successful polls (ms). */
|
|
15
|
+
intervalMs?: number;
|
|
16
|
+
/**
|
|
17
|
+
* How long one ask may hang before it is abandoned (ms). Defaults to
|
|
18
|
+
* {@link DEFAULT_ASK_TIMEOUT_MS}; consumers override it only in tests.
|
|
19
|
+
*/
|
|
20
|
+
askTimeoutMs?: number;
|
|
21
|
+
/** Poll only while `true` (e.g. after a card charge is submitted). */
|
|
22
|
+
enabled?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* WALL-CLOCK bound on the whole wait (FUT-1144): stop scheduling and report
|
|
25
|
+
* `timedOut` once this many milliseconds have passed since the wait began.
|
|
26
|
+
* Undefined ⇒ unbounded (the PIX consumer passes none — its charge expires
|
|
27
|
+
* server-side and comes back as a terminal EXPIRED).
|
|
28
|
+
*
|
|
29
|
+
* It used to be a count of HEALTHY polls, which measured the wrong thing in
|
|
30
|
+
* the only case that matters. A wait that is failing makes no healthy polls,
|
|
31
|
+
* so the count stood still while the clock ran: the hosted-return leg spun
|
|
32
|
+
* "Confirmando seu pagamento…" forever on a connection that never came back,
|
|
33
|
+
* because the only counter that could have stopped it was the one the
|
|
34
|
+
* failure had frozen. Wall time cannot be frozen by the failure it measures.
|
|
35
|
+
*/
|
|
36
|
+
maxWaitMs?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Opt-in BACKOFF: after this many healthy polls, keep asking at
|
|
39
|
+
* {@link slowIntervalMs} instead of {@link intervalMs}.
|
|
40
|
+
*
|
|
41
|
+
* A single interval cannot serve a long wait, because the two things it
|
|
42
|
+
* decides pull opposite ways. It is how fast a buyer WHO PAID learns that
|
|
43
|
+
* they did — every poll is a provider round trip, and the answer lands within
|
|
44
|
+
* seconds of the webhook — and it is also what an abandoned checkout costs
|
|
45
|
+
* for the rest of the window. Tuning one picks the other's loser: a slow
|
|
46
|
+
* interval taxes the common case (the person on this screen almost always
|
|
47
|
+
* paid) to subsidise the rare one.
|
|
48
|
+
*
|
|
49
|
+
* Splitting them costs neither. Both must be set for backoff to apply.
|
|
50
|
+
*/
|
|
51
|
+
slowAfterPolls?: number;
|
|
52
|
+
slowIntervalMs?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The slowest a failing poll may go (FUT-1144).
|
|
57
|
+
*
|
|
58
|
+
* Consecutive failures double the delay — 2.5 s, 5 s, 10 s — and stop there.
|
|
59
|
+
* The cap is what keeps the recovery cheap: a shopper whose signal comes back
|
|
60
|
+
* during a Wi-Fi→4G handoff waits at most this long to be told they paid, and
|
|
61
|
+
* the two re-arm events below usually beat it outright.
|
|
62
|
+
*/
|
|
63
|
+
const MAX_ERROR_BACKOFF_MS = 10_000;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* How long a re-arm must stay quiet after the last ask.
|
|
67
|
+
*
|
|
68
|
+
* Returning to a tab commonly fires `visibilitychange` and `online` together —
|
|
69
|
+
* and a shopper flicking between the bank app and the store fires them again
|
|
70
|
+
* per trip. This collapses a burst into one request without delaying it: the
|
|
71
|
+
* first re-arm of a burst polls immediately, the rest fall inside the gap.
|
|
72
|
+
*/
|
|
73
|
+
const REARM_QUIET_MS = 1_000;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* How long ONE status ask may take before the loop stops waiting on it.
|
|
77
|
+
*
|
|
78
|
+
* Comfortably longer than any healthy round trip on a bad link, so an ordinary
|
|
79
|
+
* slow poll is never mistaken for a dead one; short enough that a socket that
|
|
80
|
+
* died silently — the iOS case this whole feature exists for — costs one
|
|
81
|
+
* backoff step rather than the rest of the wait.
|
|
82
|
+
*/
|
|
83
|
+
const DEFAULT_ASK_TIMEOUT_MS = 15_000;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What a timed-out ask reports. It reads as any other transport failure
|
|
87
|
+
* because that is what it is to everything downstream: the counters, the
|
|
88
|
+
* backoff and the stalled-wait panel treat it exactly like a 500.
|
|
89
|
+
*/
|
|
90
|
+
const ASK_TIMED_OUT = "timeout";
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* How long before the next ask, given how many healthy polls have happened.
|
|
94
|
+
*
|
|
95
|
+
* A pure function of the options, so it lives out here rather than inside the
|
|
96
|
+
* effect — the hook is at its size gate, and a scheduling RULE is easier to
|
|
97
|
+
* read (and to test) stated once than threaded through a closure.
|
|
98
|
+
*
|
|
99
|
+
* Reads the count AFTER the poll just made, so the slow phase begins on the
|
|
100
|
+
* poll FOLLOWING the threshold rather than one early: a wait described as "N
|
|
101
|
+
* fast polls" has to actually make N of them.
|
|
102
|
+
*/
|
|
103
|
+
function healthyDelay(healthy: number, options: PollingOptions): number {
|
|
104
|
+
const { intervalMs = 2500, slowAfterPolls, slowIntervalMs } = options;
|
|
105
|
+
const backingOff =
|
|
106
|
+
slowAfterPolls !== undefined && slowIntervalMs !== undefined && healthy >= slowAfterPolls;
|
|
107
|
+
return backingOff ? slowIntervalMs : intervalMs;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The same rule with consecutive FAILURES folded in (FUT-1144).
|
|
112
|
+
*
|
|
113
|
+
* Errors never stop the wait, they only slow it. Doubling from the healthy
|
|
114
|
+
* cadence is what makes a ten-second blip cost one extra beat instead of the
|
|
115
|
+
* whole confirmation: four failures used to be terminal, and four failures is
|
|
116
|
+
* what a Wi-Fi→4G handoff produces at the default 2.5 s.
|
|
117
|
+
*
|
|
118
|
+
* The cap is `MAX_ERROR_BACKOFF_MS` or the healthy cadence, whichever is
|
|
119
|
+
* larger — a failing poll must never ask FASTER than a succeeding one, which
|
|
120
|
+
* is what a bare cap would do to a consumer whose slow interval is longer.
|
|
121
|
+
*/
|
|
122
|
+
function pollDelay(healthy: number, errors: number, options: PollingOptions): number {
|
|
123
|
+
const base = healthyDelay(healthy, options);
|
|
124
|
+
if (errors === 0) return base;
|
|
125
|
+
return Math.min(base * 2 ** (errors - 1), Math.max(base, MAX_ERROR_BACKOFF_MS));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Where a running wait writes what it has learned. */
|
|
129
|
+
interface PollSink {
|
|
130
|
+
setStatus: (status: OrderStatus) => void;
|
|
131
|
+
setError: (error: string | null) => void;
|
|
132
|
+
setTimedOut: (timedOut: boolean) => void;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The mutable bookkeeping one wait carries.
|
|
137
|
+
*
|
|
138
|
+
* `settled` and `stopped` are deliberately NOT the same flag. Settled means the
|
|
139
|
+
* order reached a terminal status and there is nothing left to ask about, ever.
|
|
140
|
+
* Stopped only means nothing is scheduled — which the wall clock running out
|
|
141
|
+
* also produces, and which the buyer's own "check again" is allowed to undo.
|
|
142
|
+
*/
|
|
143
|
+
function newRun() {
|
|
144
|
+
return {
|
|
145
|
+
cancelled: false,
|
|
146
|
+
settled: false,
|
|
147
|
+
stopped: false,
|
|
148
|
+
inFlight: false,
|
|
149
|
+
/**
|
|
150
|
+
* Which ask is the CURRENT one.
|
|
151
|
+
*
|
|
152
|
+
* A hung request cannot be un-sent, so it is abandoned instead: every ask
|
|
153
|
+
* carries the attempt it belongs to, and a late answer from a superseded
|
|
154
|
+
* attempt is dropped. Without this, `inFlight` stayed true for as long as
|
|
155
|
+
* the socket did — and `inFlight` gates the tick, the re-arm AND the
|
|
156
|
+
* buyer's own "check again", so one dead request disabled every mechanism
|
|
157
|
+
* this loop has for recovering from a dead request.
|
|
158
|
+
*/
|
|
159
|
+
attempt: 0,
|
|
160
|
+
errors: 0,
|
|
161
|
+
healthy: 0,
|
|
162
|
+
startedAt: 0,
|
|
163
|
+
askedAt: 0,
|
|
164
|
+
timer: undefined as ReturnType<typeof setTimeout> | undefined,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The mutable state of one wait — see {@link newRun}. */
|
|
169
|
+
type PollRun = ReturnType<typeof newRun>;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Give up WAITING on one ask — never on the wait itself.
|
|
173
|
+
*
|
|
174
|
+
* Resolves as an ordinary failed poll, so a hang costs exactly what a 500
|
|
175
|
+
* costs: the error counter rises, the backoff widens, and the next tick is
|
|
176
|
+
* scheduled. `run.attempt` is what makes it safe — if the real answer lands
|
|
177
|
+
* afterwards it belongs to a superseded attempt and is dropped.
|
|
178
|
+
*/
|
|
179
|
+
function askTimeout(
|
|
180
|
+
run: PollRun,
|
|
181
|
+
mine: number,
|
|
182
|
+
options: PollingOptions,
|
|
183
|
+
): Promise<Result<OrderStatus>> {
|
|
184
|
+
return new Promise((resolve) => {
|
|
185
|
+
setTimeout(() => {
|
|
186
|
+
if (run.attempt === mine) resolve({ ok: false, error: ASK_TIMED_OUT });
|
|
187
|
+
}, options.askTimeoutMs ?? DEFAULT_ASK_TIMEOUT_MS);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Fold one poll's answer into the run and into what the screen says.
|
|
193
|
+
*
|
|
194
|
+
* Returns whether the wait continues. A TERMINAL status is the only answer
|
|
195
|
+
* that can end it from here — the wall clock is the caller's business, because
|
|
196
|
+
* it has to be consulted against the delay it is about to sleep for.
|
|
197
|
+
*/
|
|
198
|
+
function absorb(run: PollRun, result: Result<OrderStatus>, sink: PollSink): boolean {
|
|
199
|
+
if (!result.ok) {
|
|
200
|
+
run.errors += 1;
|
|
201
|
+
sink.setError(result.error);
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
run.errors = 0;
|
|
205
|
+
sink.setError(null);
|
|
206
|
+
sink.setStatus(result.data);
|
|
207
|
+
if (TERMINAL_STATUSES.includes(result.data)) {
|
|
208
|
+
run.settled = true;
|
|
209
|
+
run.stopped = true;
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
run.healthy += 1;
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The handle the hook holds on one running wait. */
|
|
217
|
+
export interface PollLoop {
|
|
218
|
+
/** Reset the clock and the counters, then ask immediately. */
|
|
219
|
+
restart: () => void;
|
|
220
|
+
/** Ask immediately, keeping the clock — the re-arm events' entry point. */
|
|
221
|
+
poke: () => void;
|
|
222
|
+
/** Tear down: nothing further is scheduled and nothing further is written. */
|
|
223
|
+
stop: () => void;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* One self-scheduling wait, with no React in it.
|
|
228
|
+
*
|
|
229
|
+
* A `setTimeout` chain rather than an interval, so requests never overlap; the
|
|
230
|
+
* mutable counters live in one object because the flakiness gate's
|
|
231
|
+
* `no-global-state-mutation` is about exactly this shape, and because the
|
|
232
|
+
* whole loop has to be cancellable from a React cleanup.
|
|
233
|
+
*/
|
|
234
|
+
export function createPollLoop(
|
|
235
|
+
ask: () => Promise<Result<OrderStatus>>,
|
|
236
|
+
options: PollingOptions,
|
|
237
|
+
sink: PollSink,
|
|
238
|
+
): PollLoop {
|
|
239
|
+
const run = newRun();
|
|
240
|
+
|
|
241
|
+
const clearPending = (): void => {
|
|
242
|
+
if (run.timer !== undefined) clearTimeout(run.timer);
|
|
243
|
+
run.timer = undefined;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const outOfTime = (delay: number): boolean =>
|
|
247
|
+
options.maxWaitMs !== undefined && Date.now() - run.startedAt + delay >= options.maxWaitMs;
|
|
248
|
+
|
|
249
|
+
const tick = async (): Promise<void> => {
|
|
250
|
+
if (run.cancelled || run.settled || run.inFlight) return;
|
|
251
|
+
run.inFlight = true;
|
|
252
|
+
run.askedAt = Date.now();
|
|
253
|
+
const mine = ++run.attempt;
|
|
254
|
+
// Bounded, because the wall clock below is only consulted once an ask
|
|
255
|
+
// RETURNS: a request that never returns never reaches it, so the wait that
|
|
256
|
+
// is supposed to end after `maxWaitMs` runs forever showing "we are still
|
|
257
|
+
// trying". Racing a timer turns a hang into an ordinary failed poll, which
|
|
258
|
+
// the backoff and the re-arm already know how to handle.
|
|
259
|
+
const result = await Promise.race([ask(), askTimeout(run, mine, options)]);
|
|
260
|
+
// A superseded attempt writes nothing: it may be a hung request finally
|
|
261
|
+
// answering, long after a poke or a restart moved on.
|
|
262
|
+
if (run.attempt !== mine) return;
|
|
263
|
+
run.inFlight = false;
|
|
264
|
+
if (run.cancelled || run.settled) return;
|
|
265
|
+
if (!absorb(run, result, sink)) return;
|
|
266
|
+
const delay = pollDelay(run.healthy, run.errors, options);
|
|
267
|
+
if (outOfTime(delay)) {
|
|
268
|
+
run.stopped = true;
|
|
269
|
+
sink.setTimedOut(true);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
clearPending();
|
|
273
|
+
run.timer = setTimeout(() => void tick(), delay);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
restart: (): void => {
|
|
278
|
+
if (run.cancelled || run.settled) return;
|
|
279
|
+
clearPending();
|
|
280
|
+
// Same reasoning as `poke`, and this one is the buyer pressing a button:
|
|
281
|
+
// "Verificar de novo" that cleared the panel and sent nothing — because
|
|
282
|
+
// an ask was still notionally in flight — is the exact complaint.
|
|
283
|
+
run.attempt += 1;
|
|
284
|
+
run.inFlight = false;
|
|
285
|
+
run.stopped = false;
|
|
286
|
+
run.errors = 0;
|
|
287
|
+
run.healthy = 0;
|
|
288
|
+
run.startedAt = Date.now();
|
|
289
|
+
sink.setTimedOut(false);
|
|
290
|
+
sink.setError(null);
|
|
291
|
+
void tick();
|
|
292
|
+
},
|
|
293
|
+
poke: (): void => {
|
|
294
|
+
if (run.cancelled || run.stopped) return;
|
|
295
|
+
if (Date.now() - run.askedAt < REARM_QUIET_MS) return;
|
|
296
|
+
// Deliberately NOT gated on `inFlight`: the shopper who just came back
|
|
297
|
+
// from their bank app is exactly the case where the previous ask is a
|
|
298
|
+
// socket that died while the screen was hidden. Abandon it and ask now.
|
|
299
|
+
run.attempt += 1;
|
|
300
|
+
run.inFlight = false;
|
|
301
|
+
clearPending();
|
|
302
|
+
void tick();
|
|
303
|
+
},
|
|
304
|
+
stop: (): void => {
|
|
305
|
+
run.cancelled = true;
|
|
306
|
+
clearPending();
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
}
|
|
@@ -1,45 +1,8 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
|
|
3
3
|
import { useCheckoutClientApi } from "./client-context";
|
|
4
|
-
import type
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
interface PollingOptions {
|
|
8
|
-
/** Delay between successful polls (ms). */
|
|
9
|
-
intervalMs?: number;
|
|
10
|
-
/** Poll only while `true` (e.g. after a card charge is submitted). */
|
|
11
|
-
enabled?: boolean;
|
|
12
|
-
/**
|
|
13
|
-
* WALL-CLOCK bound on the whole wait (FUT-1144): stop scheduling and report
|
|
14
|
-
* `timedOut` once this many milliseconds have passed since the wait began.
|
|
15
|
-
* Undefined ⇒ unbounded (the PIX consumer passes none — its charge expires
|
|
16
|
-
* server-side and comes back as a terminal EXPIRED).
|
|
17
|
-
*
|
|
18
|
-
* It used to be a count of HEALTHY polls, which measured the wrong thing in
|
|
19
|
-
* the only case that matters. A wait that is failing makes no healthy polls,
|
|
20
|
-
* so the count stood still while the clock ran: the hosted-return leg spun
|
|
21
|
-
* "Confirmando seu pagamento…" forever on a connection that never came back,
|
|
22
|
-
* because the only counter that could have stopped it was the one the
|
|
23
|
-
* failure had frozen. Wall time cannot be frozen by the failure it measures.
|
|
24
|
-
*/
|
|
25
|
-
maxWaitMs?: number;
|
|
26
|
-
/**
|
|
27
|
-
* Opt-in BACKOFF: after this many healthy polls, keep asking at
|
|
28
|
-
* {@link slowIntervalMs} instead of {@link intervalMs}.
|
|
29
|
-
*
|
|
30
|
-
* A single interval cannot serve a long wait, because the two things it
|
|
31
|
-
* decides pull opposite ways. It is how fast a buyer WHO PAID learns that
|
|
32
|
-
* they did — every poll is a provider round trip, and the answer lands within
|
|
33
|
-
* seconds of the webhook — and it is also what an abandoned checkout costs
|
|
34
|
-
* for the rest of the window. Tuning one picks the other's loser: a slow
|
|
35
|
-
* interval taxes the common case (the person on this screen almost always
|
|
36
|
-
* paid) to subsidise the rare one.
|
|
37
|
-
*
|
|
38
|
-
* Splitting them costs neither. Both must be set for backoff to apply.
|
|
39
|
-
*/
|
|
40
|
-
slowAfterPolls?: number;
|
|
41
|
-
slowIntervalMs?: number;
|
|
42
|
-
}
|
|
4
|
+
import { createPollLoop, type PollLoop, type PollingOptions } from "./poll-loop";
|
|
5
|
+
import type { OrderStatus } from "./types";
|
|
43
6
|
|
|
44
7
|
/** What a consumer reads off the wait, and the one action it can take. */
|
|
45
8
|
interface PaymentPollingState {
|
|
@@ -60,179 +23,6 @@ interface PaymentPollingState {
|
|
|
60
23
|
checkAgain: () => void;
|
|
61
24
|
}
|
|
62
25
|
|
|
63
|
-
/**
|
|
64
|
-
* The slowest a failing poll may go (FUT-1144).
|
|
65
|
-
*
|
|
66
|
-
* Consecutive failures double the delay — 2.5 s, 5 s, 10 s — and stop there.
|
|
67
|
-
* The cap is what keeps the recovery cheap: a shopper whose signal comes back
|
|
68
|
-
* during a Wi-Fi→4G handoff waits at most this long to be told they paid, and
|
|
69
|
-
* the two re-arm events below usually beat it outright.
|
|
70
|
-
*/
|
|
71
|
-
const MAX_ERROR_BACKOFF_MS = 10_000;
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* How long a re-arm must stay quiet after the last ask.
|
|
75
|
-
*
|
|
76
|
-
* Returning to a tab commonly fires `visibilitychange` and `online` together —
|
|
77
|
-
* and a shopper flicking between the bank app and the store fires them again
|
|
78
|
-
* per trip. This collapses a burst into one request without delaying it: the
|
|
79
|
-
* first re-arm of a burst polls immediately, the rest fall inside the gap.
|
|
80
|
-
*/
|
|
81
|
-
const REARM_QUIET_MS = 1_000;
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* How long before the next ask, given how many healthy polls have happened.
|
|
85
|
-
*
|
|
86
|
-
* A pure function of the options, so it lives out here rather than inside the
|
|
87
|
-
* effect — the hook is at its size gate, and a scheduling RULE is easier to
|
|
88
|
-
* read (and to test) stated once than threaded through a closure.
|
|
89
|
-
*
|
|
90
|
-
* Reads the count AFTER the poll just made, so the slow phase begins on the
|
|
91
|
-
* poll FOLLOWING the threshold rather than one early: a wait described as "N
|
|
92
|
-
* fast polls" has to actually make N of them.
|
|
93
|
-
*/
|
|
94
|
-
function healthyDelay(healthy: number, options: PollingOptions): number {
|
|
95
|
-
const { intervalMs = 2500, slowAfterPolls, slowIntervalMs } = options;
|
|
96
|
-
const backingOff =
|
|
97
|
-
slowAfterPolls !== undefined && slowIntervalMs !== undefined && healthy >= slowAfterPolls;
|
|
98
|
-
return backingOff ? slowIntervalMs : intervalMs;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* The same rule with consecutive FAILURES folded in (FUT-1144).
|
|
103
|
-
*
|
|
104
|
-
* Errors never stop the wait, they only slow it. Doubling from the healthy
|
|
105
|
-
* cadence is what makes a ten-second blip cost one extra beat instead of the
|
|
106
|
-
* whole confirmation: four failures used to be terminal, and four failures is
|
|
107
|
-
* what a Wi-Fi→4G handoff produces at the default 2.5 s.
|
|
108
|
-
*
|
|
109
|
-
* The cap is `MAX_ERROR_BACKOFF_MS` or the healthy cadence, whichever is
|
|
110
|
-
* larger — a failing poll must never ask FASTER than a succeeding one, which
|
|
111
|
-
* is what a bare cap would do to a consumer whose slow interval is longer.
|
|
112
|
-
*/
|
|
113
|
-
function pollDelay(healthy: number, errors: number, options: PollingOptions): number {
|
|
114
|
-
const base = healthyDelay(healthy, options);
|
|
115
|
-
if (errors === 0) return base;
|
|
116
|
-
return Math.min(base * 2 ** (errors - 1), Math.max(base, MAX_ERROR_BACKOFF_MS));
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** Where a running wait writes what it has learned. */
|
|
120
|
-
interface PollSink {
|
|
121
|
-
setStatus: (status: OrderStatus) => void;
|
|
122
|
-
setError: (error: string | null) => void;
|
|
123
|
-
setTimedOut: (timedOut: boolean) => void;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* The mutable bookkeeping one wait carries.
|
|
128
|
-
*
|
|
129
|
-
* `settled` and `stopped` are deliberately NOT the same flag. Settled means the
|
|
130
|
-
* order reached a terminal status and there is nothing left to ask about, ever.
|
|
131
|
-
* Stopped only means nothing is scheduled — which the wall clock running out
|
|
132
|
-
* also produces, and which the buyer's own "check again" is allowed to undo.
|
|
133
|
-
*/
|
|
134
|
-
function newRun() {
|
|
135
|
-
return {
|
|
136
|
-
cancelled: false,
|
|
137
|
-
settled: false,
|
|
138
|
-
stopped: false,
|
|
139
|
-
inFlight: false,
|
|
140
|
-
errors: 0,
|
|
141
|
-
healthy: 0,
|
|
142
|
-
startedAt: 0,
|
|
143
|
-
askedAt: 0,
|
|
144
|
-
timer: undefined as ReturnType<typeof setTimeout> | undefined,
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** The handle the hook holds on one running wait. */
|
|
149
|
-
interface PollLoop {
|
|
150
|
-
/** Reset the clock and the counters, then ask immediately. */
|
|
151
|
-
restart: () => void;
|
|
152
|
-
/** Ask immediately, keeping the clock — the re-arm events' entry point. */
|
|
153
|
-
poke: () => void;
|
|
154
|
-
/** Tear down: nothing further is scheduled and nothing further is written. */
|
|
155
|
-
stop: () => void;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* One self-scheduling wait, with no React in it.
|
|
160
|
-
*
|
|
161
|
-
* A `setTimeout` chain rather than an interval, so requests never overlap; the
|
|
162
|
-
* mutable counters live in one object because the flakiness gate's
|
|
163
|
-
* `no-global-state-mutation` is about exactly this shape, and because the
|
|
164
|
-
* whole loop has to be cancellable from a React cleanup.
|
|
165
|
-
*/
|
|
166
|
-
function createPollLoop(
|
|
167
|
-
ask: () => Promise<Result<OrderStatus>>,
|
|
168
|
-
options: PollingOptions,
|
|
169
|
-
sink: PollSink,
|
|
170
|
-
): PollLoop {
|
|
171
|
-
const run = newRun();
|
|
172
|
-
|
|
173
|
-
const clearPending = (): void => {
|
|
174
|
-
if (run.timer !== undefined) clearTimeout(run.timer);
|
|
175
|
-
run.timer = undefined;
|
|
176
|
-
};
|
|
177
|
-
|
|
178
|
-
const outOfTime = (delay: number): boolean =>
|
|
179
|
-
options.maxWaitMs !== undefined && Date.now() - run.startedAt + delay >= options.maxWaitMs;
|
|
180
|
-
|
|
181
|
-
const tick = async (): Promise<void> => {
|
|
182
|
-
if (run.cancelled || run.settled || run.inFlight) return;
|
|
183
|
-
run.inFlight = true;
|
|
184
|
-
run.askedAt = Date.now();
|
|
185
|
-
const result = await ask();
|
|
186
|
-
run.inFlight = false;
|
|
187
|
-
if (run.cancelled || run.settled) return;
|
|
188
|
-
if (result.ok) {
|
|
189
|
-
run.errors = 0;
|
|
190
|
-
sink.setError(null);
|
|
191
|
-
sink.setStatus(result.data);
|
|
192
|
-
if (TERMINAL_STATUSES.includes(result.data)) {
|
|
193
|
-
run.settled = true;
|
|
194
|
-
run.stopped = true;
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
run.healthy += 1;
|
|
198
|
-
} else {
|
|
199
|
-
run.errors += 1;
|
|
200
|
-
sink.setError(result.error);
|
|
201
|
-
}
|
|
202
|
-
const delay = pollDelay(run.healthy, run.errors, options);
|
|
203
|
-
if (outOfTime(delay)) {
|
|
204
|
-
run.stopped = true;
|
|
205
|
-
sink.setTimedOut(true);
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
clearPending();
|
|
209
|
-
run.timer = setTimeout(() => void tick(), delay);
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
return {
|
|
213
|
-
restart: (): void => {
|
|
214
|
-
if (run.cancelled || run.settled) return;
|
|
215
|
-
clearPending();
|
|
216
|
-
run.stopped = false;
|
|
217
|
-
run.errors = 0;
|
|
218
|
-
run.healthy = 0;
|
|
219
|
-
run.startedAt = Date.now();
|
|
220
|
-
sink.setTimedOut(false);
|
|
221
|
-
sink.setError(null);
|
|
222
|
-
void tick();
|
|
223
|
-
},
|
|
224
|
-
poke: (): void => {
|
|
225
|
-
if (run.cancelled || run.stopped || run.inFlight) return;
|
|
226
|
-
if (Date.now() - run.askedAt < REARM_QUIET_MS) return;
|
|
227
|
-
clearPending();
|
|
228
|
-
void tick();
|
|
229
|
-
},
|
|
230
|
-
stop: (): void => {
|
|
231
|
-
run.cancelled = true;
|
|
232
|
-
clearPending();
|
|
233
|
-
},
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
26
|
|
|
237
27
|
/**
|
|
238
28
|
* Ask again the moment the shopper could plausibly have an answer for us.
|
|
@@ -283,6 +73,7 @@ export function usePaymentPolling(
|
|
|
283
73
|
maxWaitMs,
|
|
284
74
|
slowAfterPolls,
|
|
285
75
|
slowIntervalMs,
|
|
76
|
+
askTimeoutMs,
|
|
286
77
|
}: PollingOptions = {},
|
|
287
78
|
): PaymentPollingState {
|
|
288
79
|
const [status, setStatus] = useState<OrderStatus | null>(null);
|
|
@@ -301,7 +92,7 @@ export function usePaymentPolling(
|
|
|
301
92
|
|
|
302
93
|
const running = createPollLoop(
|
|
303
94
|
() => client.getStatus(orderId),
|
|
304
|
-
{ intervalMs, maxWaitMs, slowAfterPolls, slowIntervalMs },
|
|
95
|
+
{ intervalMs, maxWaitMs, slowAfterPolls, slowIntervalMs, askTimeoutMs },
|
|
305
96
|
{ setStatus, setError, setTimedOut },
|
|
306
97
|
);
|
|
307
98
|
loop.current = running;
|
|
@@ -313,7 +104,7 @@ export function usePaymentPolling(
|
|
|
313
104
|
running.stop();
|
|
314
105
|
loop.current = null;
|
|
315
106
|
};
|
|
316
|
-
}, [orderId, intervalMs, enabled, maxWaitMs, slowAfterPolls, slowIntervalMs, client]);
|
|
107
|
+
}, [orderId, intervalMs, enabled, maxWaitMs, slowAfterPolls, slowIntervalMs, askTimeoutMs, client]);
|
|
317
108
|
|
|
318
109
|
const checkAgain = useCallback(() => {
|
|
319
110
|
loop.current?.restart();
|
|
@@ -321,3 +112,4 @@ export function usePaymentPolling(
|
|
|
321
112
|
|
|
322
113
|
return { status, error, timedOut, checkAgain };
|
|
323
114
|
}
|
|
115
|
+
|