@decocms/apps-vtex 7.23.0 → 7.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,205 @@
1
+ import { RequestContext } from "@decocms/blocks/sdk/requestContext";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ createResilientFetch,
5
+ isNonRetryableVtexError,
6
+ resetResilienceState,
7
+ VtexCircuitOpenError,
8
+ VtexTimeoutError,
9
+ } from "../resilience";
10
+
11
+ function res(status: number, body = "ok"): Response {
12
+ return {
13
+ ok: status >= 200 && status < 300,
14
+ status,
15
+ statusText: "",
16
+ json: async () => body,
17
+ } as Response;
18
+ }
19
+
20
+ beforeEach(() => {
21
+ resetResilienceState();
22
+ });
23
+
24
+ describe("createResilientFetch", () => {
25
+ it("passes a healthy GET through untouched, no retry", async () => {
26
+ const underlying = vi.fn(async () => res(200, "prod"));
27
+ const rf = createResilientFetch(underlying as unknown as typeof fetch);
28
+ const r = await rf("https://acct.vtexcommercestable.com.br/ok");
29
+ expect(r.status).toBe(200);
30
+ expect(underlying).toHaveBeenCalledOnce();
31
+ });
32
+
33
+ it("never retries a POST (mutation), even on 5xx", async () => {
34
+ const underlying = vi.fn(async () => res(500));
35
+ const rf = createResilientFetch(underlying as unknown as typeof fetch);
36
+ const r = await rf("https://acct.vtexcommercestable.com.br/checkout", {
37
+ method: "POST",
38
+ body: "{}",
39
+ });
40
+ expect(r.status).toBe(500);
41
+ expect(underlying).toHaveBeenCalledOnce();
42
+ });
43
+
44
+ it("retries an idempotent GET on a thrown network error, then succeeds", async () => {
45
+ let n = 0;
46
+ const underlying = vi.fn(async () => {
47
+ n++;
48
+ if (n < 3) throw new Error("ECONNRESET");
49
+ return res(200);
50
+ });
51
+ const rf = createResilientFetch(underlying as unknown as typeof fetch, {
52
+ backoffBaseMs: 1,
53
+ backoffCapMs: 2,
54
+ });
55
+ const r = await rf("https://acct.vtexcommercestable.com.br/search");
56
+ expect(r.status).toBe(200);
57
+ expect(underlying).toHaveBeenCalledTimes(3);
58
+ });
59
+
60
+ it("opens the circuit after N consecutive failures and then fails fast", async () => {
61
+ const underlying = vi.fn(async () => res(503));
62
+ const rf = createResilientFetch(underlying as unknown as typeof fetch, {
63
+ breakerConsecutiveFailures: 5,
64
+ });
65
+ const host = "https://acct.vtexcommercestable.com.br";
66
+ // 5 POSTs (1 attempt each) → 5 consecutive breaker failures → open.
67
+ for (let i = 0; i < 5; i++) {
68
+ await rf(`${host}/p`, { method: "POST", body: "x" });
69
+ }
70
+ const before = underlying.mock.calls.length;
71
+ await expect(rf(`${host}/p`, { method: "POST", body: "x" })).rejects.toBeInstanceOf(
72
+ VtexCircuitOpenError,
73
+ );
74
+ // Fail-fast must not touch the origin.
75
+ expect(underlying.mock.calls.length).toBe(before);
76
+ });
77
+
78
+ it("half-opens after cooldown and closes on a successful probe", async () => {
79
+ vi.useFakeTimers();
80
+ try {
81
+ let healthy = false;
82
+ const underlying = vi.fn(async () => (healthy ? res(200) : res(503)));
83
+ const rf = createResilientFetch(underlying as unknown as typeof fetch, {
84
+ breakerConsecutiveFailures: 3,
85
+ breakerOpenCooldownMs: 5_000,
86
+ });
87
+ const host = "https://acct.vtexcommercestable.com.br";
88
+ for (let i = 0; i < 3; i++) await rf(`${host}/p`, { method: "POST", body: "x" });
89
+ // Open now → fail fast.
90
+ await expect(rf(`${host}/p`, { method: "POST", body: "x" })).rejects.toBeInstanceOf(
91
+ VtexCircuitOpenError,
92
+ );
93
+ // After cooldown, a probe is allowed; make it succeed.
94
+ healthy = true;
95
+ await vi.advanceTimersByTimeAsync(5_001);
96
+ const r = await rf(`${host}/p`, { method: "POST", body: "x" });
97
+ expect(r.status).toBe(200);
98
+ } finally {
99
+ vi.useRealTimers();
100
+ }
101
+ });
102
+
103
+ it("aborts a hung request with the per-attempt timeout", async () => {
104
+ vi.useFakeTimers();
105
+ try {
106
+ const underlying = vi.fn(
107
+ (_input: RequestInfo | URL, init?: RequestInit) =>
108
+ new Promise<Response>((_resolve, reject) => {
109
+ init?.signal?.addEventListener("abort", () =>
110
+ reject(Object.assign(new Error("aborted"), { name: "AbortError" })),
111
+ );
112
+ }),
113
+ );
114
+ const rf = createResilientFetch(underlying as unknown as typeof fetch, {
115
+ maxRetries: 0,
116
+ perAttemptTimeoutMs: 8_000,
117
+ });
118
+ const p = rf("https://acct.vtexcommercestable.com.br/hung");
119
+ p.catch(() => {});
120
+ await vi.advanceTimersByTimeAsync(8_001);
121
+ await expect(p).rejects.toBeInstanceOf(VtexTimeoutError);
122
+ } finally {
123
+ vi.useRealTimers();
124
+ }
125
+ });
126
+
127
+ it("aborts an in-flight VTEX call when the ambient request signal aborts", async () => {
128
+ const ac = new AbortController();
129
+ const request = new Request("https://store.example/page", { signal: ac.signal });
130
+ const underlying = vi.fn(
131
+ (_input: RequestInfo | URL, init?: RequestInit) =>
132
+ new Promise<Response>((_resolve, reject) => {
133
+ init?.signal?.addEventListener("abort", () =>
134
+ reject(Object.assign(new Error("aborted"), { name: "AbortError" })),
135
+ );
136
+ }),
137
+ );
138
+ const rf = createResilientFetch(underlying as unknown as typeof fetch, {
139
+ maxRetries: 0,
140
+ perAttemptTimeoutMs: 60_000,
141
+ });
142
+
143
+ await RequestContext.run(request, async () => {
144
+ const p = rf("https://acct.vtexcommercestable.com.br/slow");
145
+ p.catch(() => {});
146
+ ac.abort();
147
+ await expect(p).rejects.toThrow();
148
+ });
149
+ expect(underlying).toHaveBeenCalled();
150
+ });
151
+
152
+ it("does NOT count an external (caller/ambient) abort as a breaker failure", async () => {
153
+ const underlying = vi.fn(
154
+ (_input: RequestInfo | URL, init?: RequestInit) =>
155
+ new Promise<Response>((_resolve, reject) => {
156
+ init?.signal?.addEventListener("abort", () =>
157
+ reject(Object.assign(new Error("aborted"), { name: "AbortError" })),
158
+ );
159
+ }),
160
+ );
161
+ const host = "https://acct.vtexcommercestable.com.br";
162
+ const rf = createResilientFetch(underlying as unknown as typeof fetch, {
163
+ maxRetries: 0,
164
+ breakerConsecutiveFailures: 3,
165
+ });
166
+
167
+ // Abort 5 caller requests (> the 3-failure threshold). If these counted as
168
+ // breaker failures, the circuit would open against a healthy upstream.
169
+ for (let i = 0; i < 5; i++) {
170
+ const ac = new AbortController();
171
+ const p = rf(`${host}/x`, { signal: ac.signal });
172
+ p.catch(() => {});
173
+ ac.abort();
174
+ await expect(p).rejects.toThrow();
175
+ }
176
+
177
+ // Breaker must still be CLOSED: a healthy call succeeds (not VtexCircuitOpenError).
178
+ const healthy = createResilientFetch((async () => res(200)) as unknown as typeof fetch);
179
+ const r = await healthy(`${host}/x`);
180
+ expect(r.status).toBe(200);
181
+ });
182
+
183
+ it("honors the kill-switch env var", async () => {
184
+ const prev = process.env.VTEX_RESILIENCE_DISABLED;
185
+ process.env.VTEX_RESILIENCE_DISABLED = "true";
186
+ try {
187
+ const underlying = vi.fn(async () => res(200));
188
+ const rf = createResilientFetch(underlying as unknown as typeof fetch);
189
+ await rf("https://acct.vtexcommercestable.com.br/x", { method: "POST", body: "y" });
190
+ expect(underlying).toHaveBeenCalledOnce();
191
+ } finally {
192
+ if (prev === undefined) delete process.env.VTEX_RESILIENCE_DISABLED;
193
+ else process.env.VTEX_RESILIENCE_DISABLED = prev;
194
+ }
195
+ });
196
+ });
197
+
198
+ describe("isNonRetryableVtexError", () => {
199
+ it("flags circuit-open and timeout errors, not generic ones", () => {
200
+ expect(isNonRetryableVtexError(new VtexCircuitOpenError("h"))).toBe(true);
201
+ expect(isNonRetryableVtexError(new VtexTimeoutError("h", 1))).toBe(true);
202
+ expect(isNonRetryableVtexError(new Error("boom"))).toBe(false);
203
+ expect(isNonRetryableVtexError(null)).toBe(false);
204
+ });
205
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Project a VTEX OrderForm down to a requested `CartProjection`.
3
+ *
4
+ * The VTEX checkout API always returns the whole OrderForm from mutation
5
+ * endpoints (bounded only by `expectedOrderFormSections`) — there is no
6
+ * delta/patch response. So "return the minimum" is enforced **server-side**:
7
+ * we take whatever VTEX gave us and shape it into `none` / `summary` /
8
+ * `summary+items` / `minicart` / `raw` before it reaches the browser.
9
+ *
10
+ * Pure function — no I/O, fully unit-testable. All monetary values in the
11
+ * projected output are in major units (VTEX is cents).
12
+ *
13
+ * @see `@decocms/apps-commerce/types/cart` for the agnostic contract.
14
+ */
15
+
16
+ import type {
17
+ CartItemSlim,
18
+ CartOk,
19
+ CartProjection,
20
+ CartSummary,
21
+ CartSummaryWithItems,
22
+ Minicart,
23
+ } from "@decocms/apps-commerce/types";
24
+ import type { OrderForm, OrderFormItem } from "../types";
25
+ import { type VtexOrderFormToMinicartOptions, vtexOrderFormToMinicart } from "./minicart";
26
+
27
+ const CENTS_PER_MAJOR = 100;
28
+
29
+ function fromCents(cents: number | undefined | null): number {
30
+ if (cents == null || !Number.isFinite(cents)) return 0;
31
+ return cents / CENTS_PER_MAJOR;
32
+ }
33
+
34
+ /** Sum of line quantities. */
35
+ function countItems(orderForm: OrderForm): number {
36
+ return (orderForm.items ?? []).reduce((sum, i) => sum + (i.quantity ?? 0), 0);
37
+ }
38
+
39
+ function toSummary(orderForm: OrderForm): CartSummary {
40
+ return {
41
+ orderFormId: orderForm.orderFormId ?? null,
42
+ totalItems: countItems(orderForm),
43
+ total: fromCents(orderForm.value),
44
+ };
45
+ }
46
+
47
+ /** Map a VTEX line to the slim, analytics-compatible `CartItemSlim`. */
48
+ function toSlimItem(item: OrderFormItem): CartItemSlim {
49
+ return {
50
+ item_id: item.id,
51
+ item_name: item.name ?? item.skuName ?? "",
52
+ item_variant: item.skuName,
53
+ image: item.imageUrl?.replace(/^http:/, "https:") ?? "",
54
+ price: fromCents(item.sellingPrice ?? item.price),
55
+ quantity: item.quantity,
56
+ };
57
+ }
58
+
59
+ export type ProjectOrderFormOptions = VtexOrderFormToMinicartOptions;
60
+
61
+ /** The projected result type, with VTEX-concrete `minicart`/`raw` payloads. */
62
+ export type VtexCartProjectionResult =
63
+ | CartOk
64
+ | CartSummary
65
+ | CartSummaryWithItems
66
+ | Minicart<OrderForm>
67
+ | OrderForm;
68
+
69
+ /**
70
+ * Shape a VTEX OrderForm into the requested projection.
71
+ *
72
+ * @param orderForm - Raw OrderForm from a VTEX checkout call.
73
+ * @param projection - Desired client-facing shape.
74
+ * @param opts - Storefront overrides forwarded to the `minicart` projection.
75
+ */
76
+ export function projectOrderForm(
77
+ orderForm: OrderForm,
78
+ projection: CartProjection,
79
+ opts: ProjectOrderFormOptions = {},
80
+ ): VtexCartProjectionResult {
81
+ switch (projection) {
82
+ case "none":
83
+ return { ok: true };
84
+ case "summary":
85
+ return toSummary(orderForm);
86
+ case "summary+items":
87
+ return {
88
+ ...toSummary(orderForm),
89
+ items: (orderForm.items ?? []).map(toSlimItem),
90
+ };
91
+ case "minicart":
92
+ return vtexOrderFormToMinicart(orderForm, opts);
93
+ case "raw":
94
+ return orderForm;
95
+ }
96
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Tuning knobs for the VTEX fetch resilience + cache layer.
3
+ *
4
+ * Every timeout, limit, and threshold that governs how `resilience.ts`
5
+ * (abort/timeout, circuit breaker, retry budget) and `fetchCache.ts` (SWR +
6
+ * stale-if-error) behave under VTEX slowness/outages lives HERE, in one file,
7
+ * with the reasoning for each number next to it. Auditing or tuning the
8
+ * resilience posture means reading one file, not hunting across two.
9
+ */
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // fetchCache.ts — SWR + stale-if-error cache for VTEX GET responses
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /** Max distinct cache keys kept in memory; oldest `createdAt` evicted first. */
16
+ export const FETCH_CACHE_MAX_ENTRIES = 500;
17
+
18
+ /**
19
+ * How long a cached response is considered FRESH, keyed by status class.
20
+ * Past this the entry is stale — served via SWR (2xx) while a background
21
+ * refresh runs, or treated as expired (404/5xx). See `ttlForStatus` in
22
+ * `fetchCache.ts`.
23
+ */
24
+ export const FETCH_CACHE_FRESH_TTL_MS = {
25
+ /** 2xx — 3 min. Catalog/price data doesn't need to be second-fresh. */
26
+ success: 180_000,
27
+ /** 404 — 10s. Short on purpose: a just-published SKU shouldn't 404 long. */
28
+ notFound: 10_000,
29
+ /** 5xx — never "fresh". A server error is never treated as a good cache hit. */
30
+ serverError: 0,
31
+ } as const;
32
+
33
+ /**
34
+ * Stale-if-error window: how long PAST the freshness TTL a last-good 2xx entry
35
+ * may still be served when the origin is failing (5xx / timeout / circuit
36
+ * open). This is the "availability first" knob — a warm key survives a full
37
+ * VTEX outage for up to this long with zero upstream pressure; once an entry
38
+ * is older than `freshTtl + this`, it's considered too stale to serve and the
39
+ * error surfaces (→ the degraded / branded-error path takes over). Mirrors the
40
+ * edge `sie` window sites configure for the product/listing cache profiles.
41
+ */
42
+ export const FETCH_CACHE_STALE_IF_ERROR_MS = 86_400_000; // 24h
43
+
44
+ /**
45
+ * Inflight-slot backstop timeout. Bounds how long a single hung `fetch()` can
46
+ * hold a dedup entry alive. Without this, a VTEX subrequest that never
47
+ * settles leaks the inflight Map slot forever and every subsequent request
48
+ * for the same cache key joins the zombie Promise, pinning memory until
49
+ * `exceededMemory` (observed in prod: 514 hard crashes / 24h on a PLP route).
50
+ *
51
+ * MUST stay ABOVE `RESILIENCE_CONFIG.totalTimeoutMs` (see below) — the
52
+ * resilient fetch already owns real per-call abort/timeout, so this is only a
53
+ * last-resort backstop. If it fired first it would kill a slow-but-recoverable
54
+ * response the resilience layer was about to deliver, discard the good body
55
+ * (it never reaches the cache), and free the dedup slot early so a concurrent
56
+ * request launches a duplicate upstream fetch.
57
+ */
58
+ export const FETCH_CACHE_INFLIGHT_BACKSTOP_MS = 15_000;
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // resilience.ts — abort/timeout, retry budget, circuit breaker
62
+ // ---------------------------------------------------------------------------
63
+
64
+ export interface ResilienceConfig {
65
+ /** Per-attempt timeout in ms. Aborts the socket. */
66
+ perAttemptTimeoutMs: number;
67
+ /** Total time budget across all attempts (incl. backoff) in ms. */
68
+ totalTimeoutMs: number;
69
+ /** Max retries for idempotent requests (attempts = maxRetries + 1). */
70
+ maxRetries: number;
71
+ /** Exponential backoff base in ms. */
72
+ backoffBaseMs: number;
73
+ /** Exponential backoff cap in ms. */
74
+ backoffCapMs: number;
75
+ /** Consecutive failures before the breaker opens. */
76
+ breakerConsecutiveFailures: number;
77
+ /** How long the breaker stays open before half-opening, in ms. */
78
+ breakerOpenCooldownMs: number;
79
+ /** Number of probe requests allowed while half-open. */
80
+ breakerHalfOpenProbes: number;
81
+ /** Max retry tokens per host (token bucket). */
82
+ retryBudgetMax: number;
83
+ /** Token refill rate per second. */
84
+ retryBudgetRefillPerSec: number;
85
+ }
86
+
87
+ export const DEFAULT_RESILIENCE_CONFIG: ResilienceConfig = {
88
+ perAttemptTimeoutMs: 8_000,
89
+ totalTimeoutMs: 12_000,
90
+ maxRetries: 2,
91
+ backoffBaseMs: 150,
92
+ backoffCapMs: 1_000,
93
+ breakerConsecutiveFailures: 5,
94
+ breakerOpenCooldownMs: 5_000,
95
+ breakerHalfOpenProbes: 3,
96
+ retryBudgetMax: 20,
97
+ retryBudgetRefillPerSec: 5,
98
+ };