Skip to content

Commit

Permalink
Add timeout/retry handling for fetch cache (#66652)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
ijjk committed Jun 11, 2024
1 parent c16a3f9 commit efb476e
Show file tree
Hide file tree
Showing 10 changed files with 435 additions and 61 deletions.
138 changes: 87 additions & 51 deletions packages/next/src/server/lib/incremental-cache/fetch-cache.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
import type { CacheHandler, CacheHandlerContext, CacheHandlerValue } from './'
import type {
CachedFetchValue,
IncrementalCacheValue,
} from '../../response-cache'
import type { IncrementalCacheValue } from '../../response-cache'

import LRUCache from 'next/dist/compiled/lru-cache'

import { z } from 'next/dist/compiled/zod'
import type zod from 'next/dist/compiled/zod'

import {
CACHE_ONE_YEAR,
NEXT_CACHE_SOFT_TAGS_HEADER,
Expand All @@ -31,22 +24,40 @@ const CACHE_REVALIDATE_HEADER = 'x-vercel-revalidate' as const
const CACHE_FETCH_URL_HEADER = 'x-vercel-cache-item-name' as const
const CACHE_CONTROL_VALUE_HEADER = 'x-vercel-cache-control' as const

const zCachedFetchValue: zod.ZodType<CachedFetchValue> = z.object({
kind: z.literal('FETCH'),
data: z.object({
headers: z.record(z.string()),
body: z.string(),
url: z.string(),
status: z.number().optional(),
}),
tags: z.array(z.string()).optional(),
revalidate: z.number(),
})
const DEBUG = Boolean(process.env.NEXT_PRIVATE_DEBUG_CACHE)

async function fetchRetryWithTimeout(
url: Parameters<typeof fetch>[0],
init: Parameters<typeof fetch>[1],
retryIndex = 0
): Promise<Response> {
const controller = new AbortController()
const timeout = setTimeout(() => {
controller.abort()
}, 500)

return fetch(url, {
...(init || {}),
signal: controller.signal,
})
.catch((err) => {
if (retryIndex === 3) {
throw err
} else {
if (DEBUG) {
console.log(`Fetch failed for ${url} retry ${retryIndex}`)
}
return fetchRetryWithTimeout(url, init, retryIndex + 1)
}
})
.finally(() => {
clearTimeout(timeout)
})
}

export default class FetchCache implements CacheHandler {
private headers: Record<string, string>
private cacheEndpoint?: string
private debug: boolean

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

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

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

if (scHost) {
this.cacheEndpoint = `https://${scHost}${scBasePath || ''}`
if (this.debug) {
const scProto = process.env.SUSPENSE_CACHE_PROTO || 'https'
this.cacheEndpoint = `${scProto}://${scHost}${scBasePath || ''}`
if (DEBUG) {
console.log('using cache endpoint', this.cacheEndpoint)
}
} else if (this.debug) {
} else if (DEBUG) {
console.log('no cache endpoint available')
}

if (ctx.maxMemoryCacheSize) {
if (!memoryCache) {
if (this.debug) {
if (DEBUG) {
console.log('using memory store for fetch cache')
}

Expand All @@ -129,13 +140,15 @@ export default class FetchCache implements CacheHandler {
}
// rough estimate of size of cache value
return (
value.html.length + (JSON.stringify(value.pageData)?.length || 0)
value.html.length +
(JSON.stringify(value.kind === 'PAGE' && value.pageData)
?.length || 0)
)
},
})
}
} else {
if (this.debug) {
if (DEBUG) {
console.log('not using memory store for fetch cache')
}
}
Expand All @@ -145,23 +158,29 @@ export default class FetchCache implements CacheHandler {
memoryCache?.reset()
}

public async revalidateTag(tag: string) {
if (this.debug) {
console.log('revalidateTag', tag)
public async revalidateTag(
...args: Parameters<CacheHandler['revalidateTag']>
) {
let [tags] = args
tags = typeof tags === 'string' ? [tags] : tags
if (DEBUG) {
console.log('revalidateTag', tags)
}

if (!tags.length) return

if (Date.now() < rateLimitedUntil) {
if (this.debug) {
if (DEBUG) {
console.log('rate limited ', rateLimitedUntil)
}
return
}

try {
const res = await fetch(
`${
this.cacheEndpoint
}/v1/suspense-cache/revalidate?tags=${encodeURIComponent(tag)}`,
const res = await fetchRetryWithTimeout(
`${this.cacheEndpoint}/v1/suspense-cache/revalidate?tags=${tags
.map((tag) => encodeURIComponent(tag))
.join(',')}`,
{
method: 'POST',
headers: this.headers,
Expand All @@ -179,7 +198,7 @@ export default class FetchCache implements CacheHandler {
throw new Error(`Request failed with status ${res.status}.`)
}
} catch (err) {
console.warn(`Failed to revalidate tag ${tag}`, err)
console.warn(`Failed to revalidate tag ${tags}`, err)
}
}

Expand All @@ -192,7 +211,7 @@ export default class FetchCache implements CacheHandler {
}

if (Date.now() < rateLimitedUntil) {
if (this.debug) {
if (DEBUG) {
console.log('rate limited')
}
return null
Expand Down Expand Up @@ -238,7 +257,7 @@ export default class FetchCache implements CacheHandler {
}

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

const json: IncrementalCacheValue = await res.json()
const parsed = zCachedFetchValue.safeParse(json)
const cached: IncrementalCacheValue = await res.json()

if (!parsed.success) {
this.debug && console.log({ json })
if (!cached || cached.kind !== 'FETCH') {
DEBUG && console.log({ cached })
throw new Error('invalid cache value')
}

const { data: cached } = parsed

// if new tags were specified, merge those tags to the existing tags
if (cached.kind === 'FETCH') {
cached.tags ??= []
Expand All @@ -286,7 +302,7 @@ export default class FetchCache implements CacheHandler {
: Date.now() - parseInt(age || '0', 10) * 1000,
}

if (this.debug) {
if (DEBUG) {
console.log(
`got fetch cache entry for ${key}, duration: ${
Date.now() - start
Expand All @@ -303,7 +319,7 @@ export default class FetchCache implements CacheHandler {
}
} catch (err) {
// unable to get data from fetch-cache
if (this.debug) {
if (DEBUG) {
console.error(`Failed to get from fetch-cache`, err)
}
}
Expand All @@ -314,11 +330,31 @@ export default class FetchCache implements CacheHandler {

public async set(...args: Parameters<CacheHandler['set']>) {
const [key, data, ctx] = args

const newValue = data?.kind === 'FETCH' ? data.data : undefined
const existingCache = memoryCache?.get(key)
const existingValue = existingCache?.value
if (
existingValue?.kind === 'FETCH' &&
Object.keys(existingValue.data).every(
(field) =>
JSON.stringify(
(existingValue.data as Record<string, string | Object>)[field]
) ===
JSON.stringify((newValue as Record<string, string | Object>)[field])
)
) {
if (DEBUG) {
console.log(`skipping cache set for ${key} as not modified`)
}
return
}

const { fetchCache, fetchIdx, fetchUrl, tags } = ctx
if (!fetchCache) return

if (Date.now() < rateLimitedUntil) {
if (this.debug) {
if (DEBUG) {
console.log('rate limited')
}
return
Expand Down Expand Up @@ -350,7 +386,7 @@ export default class FetchCache implements CacheHandler {
tags: undefined,
})

if (this.debug) {
if (DEBUG) {
console.log('set cache', key)
}
const fetchParams: NextFetchCacheParams = {
Expand Down Expand Up @@ -379,11 +415,11 @@ export default class FetchCache implements CacheHandler {
}

if (!res.ok) {
this.debug && console.log(await res.text())
DEBUG && console.log(await res.text())
throw new Error(`invalid response ${res.status}`)
}

if (this.debug) {
if (DEBUG) {
console.log(
`successfully set to fetch-cache for ${key}, duration: ${
Date.now() - start
Expand All @@ -392,7 +428,7 @@ export default class FetchCache implements CacheHandler {
}
} catch (err) {
// unable to set to fetch-cache
if (this.debug) {
if (DEBUG) {
console.error(`Failed to update fetch cache`, err)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,22 +105,33 @@ export default class FileSystemCache implements CacheHandler {
if (this.debug) console.log('loadTagsManifest', tagsManifest)
}

public async revalidateTag(tag: string) {
public async revalidateTag(
...args: Parameters<CacheHandler['revalidateTag']>
) {
let [tags] = args
tags = typeof tags === 'string' ? [tags] : tags

if (this.debug) {
console.log('revalidateTag', tag)
console.log('revalidateTag', tags)
}

if (tags.length === 0) {
return
}

// we need to ensure the tagsManifest is refreshed
// since separate workers can be updating it at the same
// time and we can't flush out of sync data
this.loadTagsManifest()
await this.loadTagsManifest()
if (!tagsManifest || !this.tagsManifestPath) {
return
}

const data = tagsManifest.items[tag] || {}
data.revalidatedAt = Date.now()
tagsManifest.items[tag] = data
for (const tag of tags) {
const data = tagsManifest.items[tag] || {}
data.revalidatedAt = Date.now()
tagsManifest.items[tag] = data
}

try {
await this.fs.mkdir(path.dirname(this.tagsManifestPath))
Expand Down
8 changes: 5 additions & 3 deletions packages/next/src/server/lib/incremental-cache/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ export class CacheHandler {
..._args: Parameters<IncrementalCache['set']>
): Promise<void> {}

public async revalidateTag(_tag: string): Promise<void> {}
public async revalidateTag(
..._args: Parameters<IncrementalCache['revalidateTag']>
): Promise<void> {}

public resetRequestCache(): void {}
}
Expand Down Expand Up @@ -275,7 +277,7 @@ export class IncrementalCache implements IncrementalCacheType {
return unlockNext
}

async revalidateTag(tag: string) {
async revalidateTag(tags: string | string[]): Promise<void> {
if (
process.env.__NEXT_INCREMENTAL_CACHE_IPC_PORT &&
process.env.__NEXT_INCREMENTAL_CACHE_IPC_KEY &&
Expand All @@ -291,7 +293,7 @@ export class IncrementalCache implements IncrementalCacheType {
})
}

return this.cacheHandler?.revalidateTag?.(tag)
return this.cacheHandler?.revalidateTag?.(tags)
}

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

0 comments on commit efb476e

Please sign in to comment.