@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.
- package/package.json +5 -5
- package/src/utils/__tests__/fetchCache.test.ts +147 -104
- package/src/utils/__tests__/resilience.test.ts +205 -0
- package/src/utils/constants.ts +98 -0
- package/src/utils/fetchCache.ts +144 -167
- package/src/utils/instrumentedFetch.ts +52 -33
- package/src/utils/resilience.ts +352 -0
package/src/utils/fetchCache.ts
CHANGED
|
@@ -7,52 +7,35 @@
|
|
|
7
7
|
* Only caches on the server side. Keyed by full URL string.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
body: unknown;
|
|
19
|
+
status: number;
|
|
20
|
+
createdAt: number;
|
|
21
|
+
refreshing: boolean;
|
|
25
22
|
}
|
|
26
23
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
108
|
+
cacheKey: string,
|
|
109
|
+
doFetch: () => Promise<Response>,
|
|
110
|
+
opts?: FetchCacheOptions,
|
|
146
111
|
): Promise<T | null> {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
214
|
-
|
|
190
|
+
store.clear();
|
|
191
|
+
inflight.clear();
|
|
215
192
|
}
|
|
216
193
|
|
|
217
194
|
export function getFetchCacheStats() {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
}
|