@12-apps/payments-frontend 3.21.3 → 3.21.4

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",
3
+ "version": "3.21.4",
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.",
@@ -1,4 +1,5 @@
1
1
  import type { Result } from "../../result";
2
+ import { claimRearm } from "./poll-rearm";
2
3
  import { TERMINAL_STATUSES, type OrderStatus } from "./types";
3
4
 
4
5
  /**
@@ -70,16 +71,6 @@ export interface PollingOptions {
70
71
  */
71
72
  const MAX_ERROR_BACKOFF_MS = 10_000;
72
73
 
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
74
  /**
84
75
  * How long ONE status ask may take before the loop stops waiting on it.
85
76
  *
@@ -170,6 +161,8 @@ function newRun() {
170
161
  healthy: 0,
171
162
  startedAt: 0,
172
163
  askedAt: 0,
164
+ /** Live asks abandoned by a re-arm since the last answer. See `claimRearm`. */
165
+ supersededAsks: 0,
173
166
  timer: undefined as ReturnType<typeof setTimeout> | undefined,
174
167
  /**
175
168
  * The wall clock, as a timer rather than a check after an ask. `outOfTime`
@@ -215,6 +208,9 @@ function askTimeout(
215
208
  * it has to be consulted against the delay it is about to sleep for.
216
209
  */
217
210
  function absorb(run: PollRun, result: Result<OrderStatus>, sink: PollSink): boolean {
211
+ // An answer got through, so the re-arm budget is no longer being spent into
212
+ // a void: refill it whether the answer was an error or a status.
213
+ run.supersededAsks = 0;
218
214
  if (!result.ok) {
219
215
  run.errors += 1;
220
216
  sink.setError(result.error);
@@ -374,6 +370,7 @@ export function createPollLoop(
374
370
  run.stopped = false;
375
371
  run.errors = 0;
376
372
  run.healthy = 0;
373
+ run.supersededAsks = 0;
377
374
  run.startedAt = Date.now();
378
375
  armDeadline(run, options, sink);
379
376
  sink.setTimedOut(false);
@@ -382,10 +379,11 @@ export function createPollLoop(
382
379
  },
383
380
  poke: (): void => {
384
381
  if (run.cancelled || run.stopped) return;
385
- if (Date.now() - run.askedAt < REARM_QUIET_MS) return;
386
382
  // Deliberately NOT gated on `inFlight`: the shopper who just came back
387
383
  // from their bank app is exactly the case where the previous ask is a
388
- // socket that died while the screen was hidden. Abandon it and ask now.
384
+ // socket that died while the screen was hidden. Abandon it and ask now
385
+ // but `claimRearm` bounds how many live asks that may abandon in a row.
386
+ if (!claimRearm(run)) return;
389
387
  run.attempt += 1;
390
388
  run.inFlight = false;
391
389
  clearPending(run);
@@ -0,0 +1,86 @@
1
+ /**
2
+ * When a re-arm may abandon an ask that is still in flight (FUT-1259).
3
+ *
4
+ * `visibilitychange` and `online` drive the loop's `poke`, and both fire for
5
+ * the same reason the wait exists: the shopper went to their bank app and came
6
+ * back, and the request left behind is quite likely a socket that died while
7
+ * the screen was hidden. Abandoning it is right. Abandoning it EVERY time is
8
+ * what this module bounds.
9
+ */
10
+
11
+ /**
12
+ * The state a re-arm decision reads. Structural on purpose: the poll loop's own
13
+ * run object satisfies it, and typing it that way keeps this module free of an
14
+ * import back into the loop that imports it. Module-private — the loop passes
15
+ * its run and never names this type, so exporting it is an unused export.
16
+ */
17
+ interface RearmState {
18
+ inFlight: boolean;
19
+ askedAt: number;
20
+ supersededAsks: number;
21
+ }
22
+
23
+ /**
24
+ * How long a re-arm must stay quiet after the last ask.
25
+ *
26
+ * Returning to a tab commonly fires `visibilitychange` and `online` together —
27
+ * and a shopper flicking between the bank app and the store fires them again
28
+ * per trip. This collapses a burst into one request without delaying it: the
29
+ * first re-arm of a burst polls immediately, the rest fall inside the gap.
30
+ */
31
+ const REARM_QUIET_MS = 1_000;
32
+
33
+ /**
34
+ * How many live asks a re-arm may abandon before it has to let one finish.
35
+ *
36
+ * The quiet window alone looked sufficient and is not, because it is measured
37
+ * against the CURRENT ask rather than against the previous re-arm. Once a round
38
+ * trip runs longer than the window — which is the ordinary state of the flaky
39
+ * link this whole feature exists for — every re-arm supersedes an ask that was
40
+ * still alive. Nothing is ever absorbed, so `errors` never rises, so the 2.5s →
41
+ * 5s → 10s backoff never engages, and the wait sits near one request per window
42
+ * instead of decaying to one per ten.
43
+ *
44
+ * So the budget: after this many abandoned asks with no answer in between, a
45
+ * re-arm stands down and lets the in-flight ask either return or hit its own
46
+ * `askTimeoutMs`. Either outcome reaches `absorb`, which refills the budget, so
47
+ * this only bites while genuinely nothing is getting through.
48
+ *
49
+ * **What it does NOT bound: the re-arm rate itself.** A re-arm that finds the
50
+ * loop idle spends nothing, so on a link whose asks RESOLVE inside the window —
51
+ * a fast 500, or the immediate `TypeError: Load failed` iOS raises for a killed
52
+ * socket — six `online` events still cost six requests, exactly as before. That
53
+ * flavour is bounded by {@link REARM_QUIET_MS} alone and always was. This
54
+ * constant is about the ask that hangs, which is the one the quiet window
55
+ * could not see.
56
+ */
57
+ const MAX_SUPERSEDED_ASKS = 3;
58
+
59
+ /**
60
+ * Decide whether a re-arm may proceed, and account for it if it may.
61
+ *
62
+ * Named `claim` rather than `may…` because it MUTATES on success: a re-arm that
63
+ * abandons a live ask spends one of the budget above. A re-arm that finds no
64
+ * ask in flight — the loop is simply between ticks — spends nothing, since
65
+ * there is no request to amplify.
66
+ *
67
+ * That second rule is what makes a denial safe, and it is an invariant worth
68
+ * stating: `supersededAsks > 0` implies `inFlight`, because every transition to
69
+ * `inFlight === false` in a live run either passes through `absorb` (which
70
+ * refills) or is followed synchronously by a `tick`. So a re-arm arriving at an
71
+ * IDLE loop is never denied, whatever the budget reads.
72
+ *
73
+ * The cost of a denial, stated honestly: the fourth consecutive return from the
74
+ * bank app does NOT poll immediately. It waits for the in-flight ask to resolve
75
+ * or reach `askTimeoutMs` (15s by default), plus one backoff step — so worst
76
+ * case the buyer learns they paid roughly 17s later than they would have. That
77
+ * is the price of not hammering `/status` on a link that is answering nothing,
78
+ * and the buyer's own "Verificar de novo" (`restart`) resets the budget outright.
79
+ */
80
+ export function claimRearm(run: RearmState): boolean {
81
+ if (Date.now() - run.askedAt < REARM_QUIET_MS) return false;
82
+ if (!run.inFlight) return true;
83
+ if (run.supersededAsks >= MAX_SUPERSEDED_ASKS) return false;
84
+ run.supersededAsks += 1;
85
+ return true;
86
+ }