@decocms/apps-vtex 7.23.0 → 7.24.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.
@@ -7,52 +7,35 @@
7
7
  * Only caches on the server side. Keyed by full URL string.
8
8
  */
9
9
 
10
- const DEFAULT_MAX_ENTRIES = 500;
11
- const MAX_RETRIES = 2;
12
- const RETRY_DELAYS = [200, 400];
13
- // Per-attempt timeout. Bounds how long a single hung `fetch()` can hold an
14
- // inflight entry alive. Without this, a VTEX subrequest that never settles
15
- // leaks the inflight Map slot forever and every subsequent request for the
16
- // same cache key joins the zombie Promise, pinning memory until
17
- // `exceededMemory` (observed in prod: 514 hard crashes / 24h on a PLP route).
18
- const FETCH_TIMEOUT_MS = 10_000;
10
+ import {
11
+ FETCH_CACHE_FRESH_TTL_MS,
12
+ FETCH_CACHE_INFLIGHT_BACKSTOP_MS,
13
+ FETCH_CACHE_MAX_ENTRIES,
14
+ FETCH_CACHE_STALE_IF_ERROR_MS,
15
+ } from "./constants";
19
16
 
20
17
  interface CacheEntry {
21
- body: unknown;
22
- status: number;
23
- createdAt: number;
24
- refreshing: boolean;
18
+ body: unknown;
19
+ status: number;
20
+ createdAt: number;
21
+ refreshing: boolean;
25
22
  }
26
23
 
27
- const TTL_BY_STATUS: Record<string, number> = {
28
- "2xx": 180_000, // 3 min for success
29
- "404": 10_000, // 10s for not found
30
- "5xx": 0, // never cache server errors
31
- };
32
-
33
- function ttlForStatus(status: number): number {
34
- if (status >= 200 && status < 300) return TTL_BY_STATUS["2xx"];
35
- if (status === 404) return TTL_BY_STATUS["404"];
36
- if (status >= 500) return TTL_BY_STATUS["5xx"];
37
- return 0;
24
+ function freshTtlForStatus(status: number): number {
25
+ if (status >= 200 && status < 300) return FETCH_CACHE_FRESH_TTL_MS.success;
26
+ if (status === 404) return FETCH_CACHE_FRESH_TTL_MS.notFound;
27
+ if (status >= 500) return FETCH_CACHE_FRESH_TTL_MS.serverError;
28
+ return 0;
38
29
  }
39
30
 
40
31
  const store = new Map<string, CacheEntry>();
41
32
  const inflight = new Map<string, Promise<CacheEntry>>();
42
33
 
43
34
  function evictIfNeeded() {
44
- if (store.size <= DEFAULT_MAX_ENTRIES) return;
45
- const sorted = [...store.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
46
- const toRemove = sorted.slice(0, store.size - DEFAULT_MAX_ENTRIES);
47
- for (const [key] of toRemove) store.delete(key);
48
- }
49
-
50
- function sleep(ms: number): Promise<void> {
51
- return new Promise((resolve) => setTimeout(resolve, ms));
52
- }
53
-
54
- function isRetryable(response: Response): boolean {
55
- return response.status >= 500 || response.status === 429;
35
+ if (store.size <= FETCH_CACHE_MAX_ENTRIES) return;
36
+ const sorted = [...store.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
37
+ const toRemove = sorted.slice(0, store.size - FETCH_CACHE_MAX_ENTRIES);
38
+ for (const [key] of toRemove) store.delete(key);
56
39
  }
57
40
 
58
41
  /**
@@ -62,70 +45,52 @@ function isRetryable(response: Response): boolean {
62
45
  * every subsequent request for the same key joins the zombie Promise.
63
46
  */
64
47
  function withTimeout<T>(work: Promise<T>, ms: number, label: string): Promise<T> {
65
- let timer: ReturnType<typeof setTimeout> | undefined;
66
- const timeout = new Promise<never>((_, reject) => {
67
- timer = setTimeout(() => {
68
- reject(new Error(`${label} timed out after ${ms}ms`));
69
- }, ms);
70
- });
71
- return Promise.race([work, timeout]).finally(() => {
72
- clearTimeout(timer);
73
- });
48
+ let timer: ReturnType<typeof setTimeout> | undefined;
49
+ const timeout = new Promise<never>((_, reject) => {
50
+ timer = setTimeout(() => {
51
+ reject(new Error(`${label} timed out after ${ms}ms`));
52
+ }, ms);
53
+ });
54
+ return Promise.race([work, timeout]).finally(() => {
55
+ clearTimeout(timer);
56
+ });
74
57
  }
75
58
 
76
- async function executeFetch(
77
- url: string,
78
- doFetch: () => Promise<Response>,
79
- retry = true,
80
- ): Promise<CacheEntry> {
81
- let lastError: Error | undefined;
82
-
83
- const attempts = retry ? MAX_RETRIES + 1 : 1;
84
- for (let attempt = 0; attempt < attempts; attempt++) {
85
- try {
86
- const response = await doFetch();
87
-
88
- if (isRetryable(response) && attempt < attempts - 1) {
89
- console.warn(
90
- `[vtex-fetch] ${response.status} on attempt ${attempt + 1}/${attempts} — ${url}`,
91
- );
92
- await sleep(RETRY_DELAYS[attempt] ?? 400);
93
- continue;
94
- }
95
-
96
- if (response.status >= 500) {
97
- throw new Error(
98
- `fetchWithCache: ${response.status} ${response.statusText} after ${attempt + 1} attempt(s) — ${url}`,
99
- );
100
- }
101
-
102
- const body = response.ok ? await response.json() : null;
103
- return {
104
- body,
105
- status: response.status,
106
- createdAt: Date.now(),
107
- refreshing: false,
108
- };
109
- } catch (error) {
110
- lastError = error instanceof Error ? error : new Error(String(error));
111
-
112
- if (attempt < attempts - 1) {
113
- console.warn(
114
- `[vtex-fetch] attempt ${attempt + 1}/${attempts} failed — ${url}: ${lastError.message}`,
115
- );
116
- await sleep(RETRY_DELAYS[attempt] ?? 400);
117
- }
118
- }
119
- }
120
-
121
- throw lastError ?? new Error(`fetchWithCache: all ${attempts} attempts failed — ${url}`);
59
+ async function executeFetch(url: string, doFetch: () => Promise<Response>): Promise<CacheEntry> {
60
+ // Single attempt on purpose. The resilience layer (`createResilientFetch`,
61
+ // wired as the VTEX fetch's baseFetch) owns retries, backoff+jitter, the
62
+ // per-host retry budget, and the circuit breaker. Retrying here would:
63
+ // - double-retry network errors (resilience 3× × fetchCache 3× = up to 9
64
+ // upstream calls per logical request — the retry storm the budget
65
+ // prevents), and
66
+ // - re-enter the breaker on every 5xx retry, opening it ~3× too fast.
67
+ const response = await doFetch();
68
+
69
+ if (response.status >= 500) {
70
+ throw new Error(`fetchWithCache: ${response.status} ${response.statusText} — ${url}`);
71
+ }
72
+
73
+ const body = response.ok ? await response.json() : null;
74
+ return {
75
+ body,
76
+ status: response.status,
77
+ createdAt: Date.now(),
78
+ refreshing: false,
79
+ };
122
80
  }
123
81
 
124
82
  export interface FetchCacheOptions {
125
- /**
126
- * Custom TTL in ms. If provided, overrides status-based TTL.
127
- */
128
- ttl?: number;
83
+ /**
84
+ * Custom TTL in ms. If provided, overrides status-based TTL.
85
+ */
86
+ ttl?: number;
87
+ /**
88
+ * Stale-if-error window in ms. How long past the freshness TTL a last-good
89
+ * entry may still be served while the origin is failing. Defaults to
90
+ * {@link FETCH_CACHE_STALE_IF_ERROR_MS} (24h). Set to 0 to disable stale
91
+ * serving.
92
+ */
93
+ sieMs?: number;
129
94
  }
130
95
 
131
96
  /**
@@ -140,83 +105,95 @@ export interface FetchCacheOptions {
140
105
  * @returns Parsed JSON body, or null for cacheable error responses (e.g. 404)
141
106
  */
142
107
  export function fetchWithCache<T>(
143
- cacheKey: string,
144
- doFetch: () => Promise<Response>,
145
- opts?: FetchCacheOptions,
108
+ cacheKey: string,
109
+ doFetch: () => Promise<Response>,
110
+ opts?: FetchCacheOptions,
146
111
  ): Promise<T | null> {
147
- const now = Date.now();
148
- const entry = store.get(cacheKey);
149
-
150
- if (entry) {
151
- const maxAge = opts?.ttl ?? ttlForStatus(entry.status);
152
- const isStale = now - entry.createdAt > maxAge;
153
-
154
- if (!isStale) return Promise.resolve(entry.body as T | null);
155
-
156
- if (isStale && !entry.refreshing) {
157
- entry.refreshing = true;
158
- // Background refresh: no retry — stale data is already being served.
159
- // Timeout guards against a hung VTEX response leaving `refreshing`
160
- // stuck true forever (which would silently disable revalidation).
161
- withTimeout(
162
- executeFetch(cacheKey, doFetch, false),
163
- FETCH_TIMEOUT_MS,
164
- `fetchCache stale-refresh ${cacheKey}`,
165
- )
166
- .then((fresh) => {
167
- const ttl = opts?.ttl ?? ttlForStatus(fresh.status);
168
- const existingWasSuccess = entry.status >= 200 && entry.status < 300;
169
- const freshIsError = fresh.status >= 400;
170
- const wouldDowngrade = existingWasSuccess && freshIsError;
171
- if (ttl > 0 && !wouldDowngrade) {
172
- store.set(cacheKey, fresh);
173
- } else {
174
- entry.refreshing = false;
175
- }
176
- })
177
- .catch(() => {
178
- entry.refreshing = false;
179
- });
180
- return Promise.resolve(entry.body as T | null);
181
- }
182
-
183
- return Promise.resolve(entry.body as T | null);
184
- }
185
-
186
- const existing = inflight.get(cacheKey);
187
- if (existing) return existing.then((e) => e.body as T | null);
188
-
189
- // Wrap with a timeout so the `.finally()` below always runs and evicts
190
- // the inflight slot — even if `executeFetch` never settles. See the
191
- // FETCH_TIMEOUT_MS comment at the top of this file for the leak this
192
- // guards against.
193
- const promise = withTimeout(
194
- executeFetch(cacheKey, doFetch),
195
- FETCH_TIMEOUT_MS,
196
- `fetchCache ${cacheKey}`,
197
- )
198
- .then((fresh) => {
199
- const ttl = opts?.ttl ?? ttlForStatus(fresh.status);
200
- if (ttl > 0) {
201
- store.set(cacheKey, fresh);
202
- evictIfNeeded();
203
- }
204
- return fresh;
205
- })
206
- .finally(() => inflight.delete(cacheKey));
207
-
208
- inflight.set(cacheKey, promise);
209
- return promise.then((e) => e.body as T | null);
112
+ const now = Date.now();
113
+ const entry = store.get(cacheKey);
114
+
115
+ if (entry) {
116
+ const maxAge = opts?.ttl ?? freshTtlForStatus(entry.status);
117
+ const age = now - entry.createdAt;
118
+ const isStale = age > maxAge;
119
+
120
+ if (!isStale) return Promise.resolve(entry.body as T | null);
121
+
122
+ // Beyond the stale-if-error window the last-good entry is too old to keep
123
+ // serving during an outage: drop it and fall through to a foreground
124
+ // refetch (cold path). On a healthy origin this is never reached because
125
+ // the background refresh below keeps resetting `createdAt`.
126
+ const sieMs = opts?.sieMs ?? FETCH_CACHE_STALE_IF_ERROR_MS;
127
+ const tooStale = age > maxAge + sieMs;
128
+
129
+ if (!tooStale) {
130
+ if (!entry.refreshing) {
131
+ entry.refreshing = true;
132
+ // Background refresh: no retry — stale data is already being served.
133
+ // Timeout guards against a hung VTEX response leaving `refreshing`
134
+ // stuck true forever (which would silently disable revalidation).
135
+ withTimeout(
136
+ executeFetch(cacheKey, doFetch),
137
+ FETCH_CACHE_INFLIGHT_BACKSTOP_MS,
138
+ `fetchCache stale-refresh ${cacheKey}`,
139
+ )
140
+ .then((fresh) => {
141
+ const ttl = opts?.ttl ?? freshTtlForStatus(fresh.status);
142
+ const existingWasSuccess = entry.status >= 200 && entry.status < 300;
143
+ const freshIsError = fresh.status >= 400;
144
+ const wouldDowngrade = existingWasSuccess && freshIsError;
145
+ if (ttl > 0 && !wouldDowngrade) {
146
+ store.set(cacheKey, fresh);
147
+ } else {
148
+ entry.refreshing = false;
149
+ }
150
+ })
151
+ .catch(() => {
152
+ entry.refreshing = false;
153
+ });
154
+ }
155
+ // Serve last-good while it is within the SIE window (stale-if-error).
156
+ return Promise.resolve(entry.body as T | null);
157
+ }
158
+
159
+ store.delete(cacheKey);
160
+ // fall through to the cold path below with the dead entry removed
161
+ }
162
+
163
+ const existing = inflight.get(cacheKey);
164
+ if (existing) return existing.then((e) => e.body as T | null);
165
+
166
+ // Wrap with a timeout so the `.finally()` below always runs and evicts
167
+ // the inflight slot — even if `executeFetch` never settles. See the
168
+ // FETCH_CACHE_INFLIGHT_BACKSTOP_MS doc comment (constants.ts) for the leak
169
+ // this guards against.
170
+ const promise = withTimeout(
171
+ executeFetch(cacheKey, doFetch),
172
+ FETCH_CACHE_INFLIGHT_BACKSTOP_MS,
173
+ `fetchCache ${cacheKey}`,
174
+ )
175
+ .then((fresh) => {
176
+ const ttl = opts?.ttl ?? freshTtlForStatus(fresh.status);
177
+ if (ttl > 0) {
178
+ store.set(cacheKey, fresh);
179
+ evictIfNeeded();
180
+ }
181
+ return fresh;
182
+ })
183
+ .finally(() => inflight.delete(cacheKey));
184
+
185
+ inflight.set(cacheKey, promise);
186
+ return promise.then((e) => e.body as T | null);
210
187
  }
211
188
 
212
189
  export function clearFetchCache() {
213
- store.clear();
214
- inflight.clear();
190
+ store.clear();
191
+ inflight.clear();
215
192
  }
216
193
 
217
194
  export function getFetchCacheStats() {
218
- return {
219
- entries: store.size,
220
- inflight: inflight.size,
221
- };
195
+ return {
196
+ entries: store.size,
197
+ inflight: inflight.size,
198
+ };
222
199
  }
@@ -31,27 +31,37 @@
31
31
  */
32
32
 
33
33
  import {
34
- createInstrumentedFetch,
35
- type InstrumentedFetch,
34
+ createInstrumentedFetch,
35
+ type InstrumentedFetch,
36
36
  } from "@decocms/blocks/sdk/instrumentedFetch";
37
- import { recordCommerceMetric } from "@decocms/blocks/sdk/observability";
37
+ import { recordCommerceMetric, statusClassFor } from "@decocms/blocks/sdk/observability";
38
38
  import { vtexOperationRouter } from "./operationRouter";
39
+ import { createResilientFetch, type ResilienceConfig } from "./resilience";
39
40
 
40
41
  export interface CreateVtexFetchOptions {
41
- /**
42
- * Underlying fetch to wrap. Defaults to `globalThis.fetch`.
43
- * Pass an existing custom fetch (e.g. one that injects auth cookies
44
- * or routes through a proxy) to preserve its behavior while adding
45
- * the VTEX instrumentation layer on top.
46
- */
47
- baseFetch?: typeof fetch;
48
- /**
49
- * Disable the `http.client.request.duration` histogram emission for
50
- * VTEX calls. The framework's span and structured logs still emit.
51
- * Useful when the consumer wants to record its own histogram with a
52
- * custom shape. Default: false.
53
- */
54
- disableHistogram?: boolean;
42
+ /**
43
+ * Underlying fetch to wrap. Defaults to `globalThis.fetch`.
44
+ * Pass an existing custom fetch (e.g. one that injects auth cookies
45
+ * or routes through a proxy) to preserve its behavior while adding
46
+ * the VTEX instrumentation layer on top.
47
+ */
48
+ baseFetch?: typeof fetch;
49
+ /**
50
+ * Disable the `http.client.request.duration` histogram emission for
51
+ * VTEX calls. The framework's span and structured logs still emit.
52
+ * Useful when the consumer wants to record its own histogram with a
53
+ * custom shape. Default: false.
54
+ */
55
+ disableHistogram?: boolean;
56
+ /**
57
+ * Resilience layer (real abort/timeout + per-host circuit breaker +
58
+ * idempotent-only retry with a budget). ON by default so every VTEX
59
+ * consumer is protected against upstream slowness/outages without
60
+ * per-site wiring. Pass a partial config to tune, `false` to opt out,
61
+ * or flip the `VTEX_RESILIENCE_DISABLED=true` env var as a runtime
62
+ * kill-switch. See {@link createResilientFetch}.
63
+ */
64
+ resilience?: Partial<ResilienceConfig> | false;
55
65
  }
56
66
 
57
67
  /**
@@ -59,20 +69,29 @@ export interface CreateVtexFetchOptions {
59
69
  * `setVtexFetch(...)`. See module docstring for details.
60
70
  */
61
71
  export function createVtexFetch(options: CreateVtexFetchOptions = {}): InstrumentedFetch {
62
- const { baseFetch, disableHistogram = false } = options;
63
- return createInstrumentedFetch({
64
- name: "vtex",
65
- baseFetch,
66
- resolveOperation: vtexOperationRouter,
67
- onComplete: disableHistogram
68
- ? undefined
69
- : ({ operation, status, durationMs, cached }) => {
70
- recordCommerceMetric(durationMs, {
71
- provider: "vtex",
72
- operation,
73
- status_class: `${Math.floor(status / 100)}xx`,
74
- cached,
75
- });
76
- },
77
- });
72
+ const { baseFetch, disableHistogram = false, resilience } = options;
73
+ // Compose the resilience layer UNDER the instrumentation so spans/histograms
74
+ // see the real outcome (including breaker fast-fails and timeouts). A single
75
+ // instrumented call = one logical request, which may span several underlying
76
+ // attempts. Covers every VTEX egress path — cached GETs, IS, and checkout
77
+ // POSTs — because they all funnel through this one fetch.
78
+ const resilientBase =
79
+ resilience === false
80
+ ? baseFetch
81
+ : createResilientFetch(baseFetch ?? globalThis.fetch, resilience ?? {});
82
+ return createInstrumentedFetch({
83
+ name: "vtex",
84
+ baseFetch: resilientBase,
85
+ resolveOperation: vtexOperationRouter,
86
+ onComplete: disableHistogram
87
+ ? undefined
88
+ : ({ operation, status, durationMs, cached }) => {
89
+ recordCommerceMetric(durationMs, {
90
+ provider: "vtex",
91
+ operation,
92
+ status_class: statusClassFor(status),
93
+ cached,
94
+ });
95
+ },
96
+ });
78
97
  }