Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add timeout/retry handling for fetch cache #66652

Merged
merged 15 commits into from
Jun 10, 2024
72 changes: 51 additions & 21 deletions packages/next/src/server/lib/incremental-cache/fetch-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +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 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 @@ -53,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 @@ -79,17 +108,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 Down Expand Up @@ -118,7 +148,7 @@ export default class FetchCache implements CacheHandler {
})
}
} else {
if (this.debug) {
if (DEBUG) {
console.log('not using memory store for fetch cache')
}
}
Expand All @@ -133,21 +163,21 @@ export default class FetchCache implements CacheHandler {
) {
let [tags] = args
tags = typeof tags === 'string' ? [tags] : tags
if (this.debug) {
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(
const res = await fetchRetryWithTimeout(
`${this.cacheEndpoint}/v1/suspense-cache/revalidate?tags=${tags
.map((tag) => encodeURIComponent(tag))
.join(',')}`,
Expand Down Expand Up @@ -181,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 @@ -227,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 @@ -245,7 +275,7 @@ export default class FetchCache implements CacheHandler {
const cached: IncrementalCacheValue = await res.json()

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

Expand All @@ -272,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 @@ -289,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,7 +344,7 @@ export default class FetchCache implements CacheHandler {
JSON.stringify((newValue as Record<string, string | Object>)[field])
)
) {
if (this.debug) {
if (DEBUG) {
console.log(`skipping cache set for ${key} as not modified`)
}
return
Expand All @@ -324,7 +354,7 @@ export default class FetchCache implements CacheHandler {
if (!fetchCache) return

if (Date.now() < rateLimitedUntil) {
if (this.debug) {
if (DEBUG) {
console.log('rate limited')
}
return
Expand Down Expand Up @@ -356,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 @@ -385,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 @@ -398,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
10 changes: 9 additions & 1 deletion packages/next/src/server/web/spec-extension/unstable-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export function unstable_cache<T extends Callback>(
return a.localeCompare(b)
})
const sortedSearch = sortedSearchKeys
.map((key) => searchParams.get(key))
.map((key) => `${key}=${searchParams.get(key)}`)
.join('&')

// Construct the complete cache key for this function invocation
Expand Down Expand Up @@ -180,6 +180,7 @@ export function unstable_cache<T extends Callback>(
tags,
softTags: implicitTags,
fetchIdx,
fetchUrl,
})

if (cacheEntry && cacheEntry.value) {
Expand Down Expand Up @@ -276,10 +277,17 @@ export function unstable_cache<T extends Callback>(
if (!incrementalCache.isOnDemandRevalidate) {
// We aren't doing an on demand revalidation so we check use the cache if valid

// @TODO check on this API. addImplicitTags mutates the store and returns the implicit tags. The naming
// of this function is potentially a little confusing
const implicitTags = store && addImplicitTags(store)

const cacheEntry = await incrementalCache.get(cacheKey, {
kindHint: 'fetch',
revalidate: options.revalidate,
tags,
fetchIdx,
fetchUrl,
softTags: implicitTags,
})

if (cacheEntry && cacheEntry.value) {
Expand Down
10 changes: 10 additions & 0 deletions test/ppr-tests-manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
{
"version": 2,
"suites": {
"test/production/app-dir/fetch-cache/fetch-cache.test.ts": {
"passed": [],
"failed": [
"fetch-cache should have correct fetchUrl field for fetches and unstable_cache",
"fetch-cache should retry 3 times when revalidate times out"
],
"pending": [],
"flakey": [],
"runtimeError": false
},
"test/e2e/app-dir/app-static/app-static.test.ts": {
"failed": [
"app-dir static/dynamic handling usePathname should have values from canonical url on rewrite",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'

export const dynamic = 'force-dynamic'

export function GET(req: NextRequest) {
revalidateTag('thankyounext')
return NextResponse.json({ done: true })
}
8 changes: 8 additions & 0 deletions test/production/app-dir/fetch-cache/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ReactNode } from 'react'
export default function Root({ children }: { children: ReactNode }) {
return (
<html>
<body>{children}</body>
</html>
)
}
32 changes: 32 additions & 0 deletions test/production/app-dir/fetch-cache/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { unstable_cache } from 'next/cache'

export const dynamic = 'force-dynamic'
export const fetchCache = 'default-cache'

const getCachedRandom = unstable_cache(
async () => {
return Math.random()
},
[],
{
revalidate: 3,
tags: ['thankyounext'],
}
)

export default async function Page() {
const data = await fetch(
'https://next-data-api-endpoint.vercel.app/api/random?a=b',
{ next: { tags: ['thankyounext'] } }
).then((res) => res.text())

const cachedRandom = getCachedRandom()

return (
<>
<p>hello world</p>
<p id="data">{data}</p>
<p id="random">{cachedRandom}</p>
</>
)
}
Loading
Loading