Skip to content

Commit efb476e

Browse files
committed
Add timeout/retry handling for fetch cache (#66652)
As discussed this adds handling to timeout at a max of 500ms for fetch cache request and retries a max of 3 times due to network instability. This also adds cache service tests and fixes a case we've been trying to track down where we were seeing `undefined` cache URL values which made debugging fetches tricky. # Conflicts: # packages/next/src/server/base-server.ts # packages/next/src/server/lib/incremental-cache/fetch-cache.ts # packages/next/src/server/web/spec-extension/unstable-cache.ts
1 parent c16a3f9 commit efb476e

File tree

10 files changed

+435
-61
lines changed

10 files changed

+435
-61
lines changed

packages/next/src/server/lib/incremental-cache/fetch-cache.ts

Lines changed: 87 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,7 @@
11
import type { CacheHandler, CacheHandlerContext, CacheHandlerValue } from './'
2-
import type {
3-
CachedFetchValue,
4-
IncrementalCacheValue,
5-
} from '../../response-cache'
2+
import type { IncrementalCacheValue } from '../../response-cache'
63

74
import LRUCache from 'next/dist/compiled/lru-cache'
8-
9-
import { z } from 'next/dist/compiled/zod'
10-
import type zod from 'next/dist/compiled/zod'
11-
125
import {
136
CACHE_ONE_YEAR,
147
NEXT_CACHE_SOFT_TAGS_HEADER,
@@ -31,22 +24,40 @@ const CACHE_REVALIDATE_HEADER = 'x-vercel-revalidate' as const
3124
const CACHE_FETCH_URL_HEADER = 'x-vercel-cache-item-name' as const
3225
const CACHE_CONTROL_VALUE_HEADER = 'x-vercel-cache-control' as const
3326

34-
const zCachedFetchValue: zod.ZodType<CachedFetchValue> = z.object({
35-
kind: z.literal('FETCH'),
36-
data: z.object({
37-
headers: z.record(z.string()),
38-
body: z.string(),
39-
url: z.string(),
40-
status: z.number().optional(),
41-
}),
42-
tags: z.array(z.string()).optional(),
43-
revalidate: z.number(),
44-
})
27+
const DEBUG = Boolean(process.env.NEXT_PRIVATE_DEBUG_CACHE)
28+
29+
async function fetchRetryWithTimeout(
30+
url: Parameters<typeof fetch>[0],
31+
init: Parameters<typeof fetch>[1],
32+
retryIndex = 0
33+
): Promise<Response> {
34+
const controller = new AbortController()
35+
const timeout = setTimeout(() => {
36+
controller.abort()
37+
}, 500)
38+
39+
return fetch(url, {
40+
...(init || {}),
41+
signal: controller.signal,
42+
})
43+
.catch((err) => {
44+
if (retryIndex === 3) {
45+
throw err
46+
} else {
47+
if (DEBUG) {
48+
console.log(`Fetch failed for ${url} retry ${retryIndex}`)
49+
}
50+
return fetchRetryWithTimeout(url, init, retryIndex + 1)
51+
}
52+
})
53+
.finally(() => {
54+
clearTimeout(timeout)
55+
})
56+
}
4557

4658
export default class FetchCache implements CacheHandler {
4759
private headers: Record<string, string>
4860
private cacheEndpoint?: string
49-
private debug: boolean
5061

5162
private hasMatchingTags(arr1: string[], arr2: string[]) {
5263
if (arr1.length !== arr2.length) return false
@@ -72,7 +83,6 @@ export default class FetchCache implements CacheHandler {
7283
}
7384

7485
constructor(ctx: CacheHandlerContext) {
75-
this.debug = !!process.env.NEXT_PRIVATE_DEBUG_CACHE
7686
this.headers = {}
7787
this.headers['Content-Type'] = 'application/json'
7888

@@ -99,17 +109,18 @@ export default class FetchCache implements CacheHandler {
99109
}
100110

101111
if (scHost) {
102-
this.cacheEndpoint = `https://${scHost}${scBasePath || ''}`
103-
if (this.debug) {
112+
const scProto = process.env.SUSPENSE_CACHE_PROTO || 'https'
113+
this.cacheEndpoint = `${scProto}://${scHost}${scBasePath || ''}`
114+
if (DEBUG) {
104115
console.log('using cache endpoint', this.cacheEndpoint)
105116
}
106-
} else if (this.debug) {
117+
} else if (DEBUG) {
107118
console.log('no cache endpoint available')
108119
}
109120

110121
if (ctx.maxMemoryCacheSize) {
111122
if (!memoryCache) {
112-
if (this.debug) {
123+
if (DEBUG) {
113124
console.log('using memory store for fetch cache')
114125
}
115126

@@ -129,13 +140,15 @@ export default class FetchCache implements CacheHandler {
129140
}
130141
// rough estimate of size of cache value
131142
return (
132-
value.html.length + (JSON.stringify(value.pageData)?.length || 0)
143+
value.html.length +
144+
(JSON.stringify(value.kind === 'PAGE' && value.pageData)
145+
?.length || 0)
133146
)
134147
},
135148
})
136149
}
137150
} else {
138-
if (this.debug) {
151+
if (DEBUG) {
139152
console.log('not using memory store for fetch cache')
140153
}
141154
}
@@ -145,23 +158,29 @@ export default class FetchCache implements CacheHandler {
145158
memoryCache?.reset()
146159
}
147160

148-
public async revalidateTag(tag: string) {
149-
if (this.debug) {
150-
console.log('revalidateTag', tag)
161+
public async revalidateTag(
162+
...args: Parameters<CacheHandler['revalidateTag']>
163+
) {
164+
let [tags] = args
165+
tags = typeof tags === 'string' ? [tags] : tags
166+
if (DEBUG) {
167+
console.log('revalidateTag', tags)
151168
}
152169

170+
if (!tags.length) return
171+
153172
if (Date.now() < rateLimitedUntil) {
154-
if (this.debug) {
173+
if (DEBUG) {
155174
console.log('rate limited ', rateLimitedUntil)
156175
}
157176
return
158177
}
159178

160179
try {
161-
const res = await fetch(
162-
`${
163-
this.cacheEndpoint
164-
}/v1/suspense-cache/revalidate?tags=${encodeURIComponent(tag)}`,
180+
const res = await fetchRetryWithTimeout(
181+
`${this.cacheEndpoint}/v1/suspense-cache/revalidate?tags=${tags
182+
.map((tag) => encodeURIComponent(tag))
183+
.join(',')}`,
165184
{
166185
method: 'POST',
167186
headers: this.headers,
@@ -179,7 +198,7 @@ export default class FetchCache implements CacheHandler {
179198
throw new Error(`Request failed with status ${res.status}.`)
180199
}
181200
} catch (err) {
182-
console.warn(`Failed to revalidate tag ${tag}`, err)
201+
console.warn(`Failed to revalidate tag ${tags}`, err)
183202
}
184203
}
185204

@@ -192,7 +211,7 @@ export default class FetchCache implements CacheHandler {
192211
}
193212

194213
if (Date.now() < rateLimitedUntil) {
195-
if (this.debug) {
214+
if (DEBUG) {
196215
console.log('rate limited')
197216
}
198217
return null
@@ -238,7 +257,7 @@ export default class FetchCache implements CacheHandler {
238257
}
239258

240259
if (res.status === 404) {
241-
if (this.debug) {
260+
if (DEBUG) {
242261
console.log(
243262
`no fetch cache entry for ${key}, duration: ${
244263
Date.now() - start
@@ -253,16 +272,13 @@ export default class FetchCache implements CacheHandler {
253272
throw new Error(`invalid response from cache ${res.status}`)
254273
}
255274

256-
const json: IncrementalCacheValue = await res.json()
257-
const parsed = zCachedFetchValue.safeParse(json)
275+
const cached: IncrementalCacheValue = await res.json()
258276

259-
if (!parsed.success) {
260-
this.debug && console.log({ json })
277+
if (!cached || cached.kind !== 'FETCH') {
278+
DEBUG && console.log({ cached })
261279
throw new Error('invalid cache value')
262280
}
263281

264-
const { data: cached } = parsed
265-
266282
// if new tags were specified, merge those tags to the existing tags
267283
if (cached.kind === 'FETCH') {
268284
cached.tags ??= []
@@ -286,7 +302,7 @@ export default class FetchCache implements CacheHandler {
286302
: Date.now() - parseInt(age || '0', 10) * 1000,
287303
}
288304

289-
if (this.debug) {
305+
if (DEBUG) {
290306
console.log(
291307
`got fetch cache entry for ${key}, duration: ${
292308
Date.now() - start
@@ -303,7 +319,7 @@ export default class FetchCache implements CacheHandler {
303319
}
304320
} catch (err) {
305321
// unable to get data from fetch-cache
306-
if (this.debug) {
322+
if (DEBUG) {
307323
console.error(`Failed to get from fetch-cache`, err)
308324
}
309325
}
@@ -314,11 +330,31 @@ export default class FetchCache implements CacheHandler {
314330

315331
public async set(...args: Parameters<CacheHandler['set']>) {
316332
const [key, data, ctx] = args
333+
334+
const newValue = data?.kind === 'FETCH' ? data.data : undefined
335+
const existingCache = memoryCache?.get(key)
336+
const existingValue = existingCache?.value
337+
if (
338+
existingValue?.kind === 'FETCH' &&
339+
Object.keys(existingValue.data).every(
340+
(field) =>
341+
JSON.stringify(
342+
(existingValue.data as Record<string, string | Object>)[field]
343+
) ===
344+
JSON.stringify((newValue as Record<string, string | Object>)[field])
345+
)
346+
) {
347+
if (DEBUG) {
348+
console.log(`skipping cache set for ${key} as not modified`)
349+
}
350+
return
351+
}
352+
317353
const { fetchCache, fetchIdx, fetchUrl, tags } = ctx
318354
if (!fetchCache) return
319355

320356
if (Date.now() < rateLimitedUntil) {
321-
if (this.debug) {
357+
if (DEBUG) {
322358
console.log('rate limited')
323359
}
324360
return
@@ -350,7 +386,7 @@ export default class FetchCache implements CacheHandler {
350386
tags: undefined,
351387
})
352388

353-
if (this.debug) {
389+
if (DEBUG) {
354390
console.log('set cache', key)
355391
}
356392
const fetchParams: NextFetchCacheParams = {
@@ -379,11 +415,11 @@ export default class FetchCache implements CacheHandler {
379415
}
380416

381417
if (!res.ok) {
382-
this.debug && console.log(await res.text())
418+
DEBUG && console.log(await res.text())
383419
throw new Error(`invalid response ${res.status}`)
384420
}
385421

386-
if (this.debug) {
422+
if (DEBUG) {
387423
console.log(
388424
`successfully set to fetch-cache for ${key}, duration: ${
389425
Date.now() - start
@@ -392,7 +428,7 @@ export default class FetchCache implements CacheHandler {
392428
}
393429
} catch (err) {
394430
// unable to set to fetch-cache
395-
if (this.debug) {
431+
if (DEBUG) {
396432
console.error(`Failed to update fetch cache`, err)
397433
}
398434
}

packages/next/src/server/lib/incremental-cache/file-system-cache.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -105,22 +105,33 @@ export default class FileSystemCache implements CacheHandler {
105105
if (this.debug) console.log('loadTagsManifest', tagsManifest)
106106
}
107107

108-
public async revalidateTag(tag: string) {
108+
public async revalidateTag(
109+
...args: Parameters<CacheHandler['revalidateTag']>
110+
) {
111+
let [tags] = args
112+
tags = typeof tags === 'string' ? [tags] : tags
113+
109114
if (this.debug) {
110-
console.log('revalidateTag', tag)
115+
console.log('revalidateTag', tags)
116+
}
117+
118+
if (tags.length === 0) {
119+
return
111120
}
112121

113122
// we need to ensure the tagsManifest is refreshed
114123
// since separate workers can be updating it at the same
115124
// time and we can't flush out of sync data
116-
this.loadTagsManifest()
125+
await this.loadTagsManifest()
117126
if (!tagsManifest || !this.tagsManifestPath) {
118127
return
119128
}
120129

121-
const data = tagsManifest.items[tag] || {}
122-
data.revalidatedAt = Date.now()
123-
tagsManifest.items[tag] = data
130+
for (const tag of tags) {
131+
const data = tagsManifest.items[tag] || {}
132+
data.revalidatedAt = Date.now()
133+
tagsManifest.items[tag] = data
134+
}
124135

125136
try {
126137
await this.fs.mkdir(path.dirname(this.tagsManifestPath))

packages/next/src/server/lib/incremental-cache/index.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ export class CacheHandler {
5959
..._args: Parameters<IncrementalCache['set']>
6060
): Promise<void> {}
6161

62-
public async revalidateTag(_tag: string): Promise<void> {}
62+
public async revalidateTag(
63+
..._args: Parameters<IncrementalCache['revalidateTag']>
64+
): Promise<void> {}
6365

6466
public resetRequestCache(): void {}
6567
}
@@ -275,7 +277,7 @@ export class IncrementalCache implements IncrementalCacheType {
275277
return unlockNext
276278
}
277279

278-
async revalidateTag(tag: string) {
280+
async revalidateTag(tags: string | string[]): Promise<void> {
279281
if (
280282
process.env.__NEXT_INCREMENTAL_CACHE_IPC_PORT &&
281283
process.env.__NEXT_INCREMENTAL_CACHE_IPC_KEY &&
@@ -291,7 +293,7 @@ export class IncrementalCache implements IncrementalCacheType {
291293
})
292294
}
293295

294-
return this.cacheHandler?.revalidateTag?.(tag)
296+
return this.cacheHandler?.revalidateTag?.(tags)
295297
}
296298

297299
// x-ref: https://github.com/facebook/react/blob/2655c9354d8e1c54ba888444220f63e836925caa/packages/react/src/ReactFetch.js#L23

0 commit comments

Comments
 (0)