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.
This commit is contained in:
parent
eb9f49b07e
commit
755c9e445b
9 changed files with 371 additions and 23 deletions
|
@ -2792,7 +2792,6 @@ export default abstract class Server<
|
||||||
kind: 'PAGE',
|
kind: 'PAGE',
|
||||||
html: RenderResult.fromStatic(''),
|
html: RenderResult.fromStatic(''),
|
||||||
pageData: {},
|
pageData: {},
|
||||||
postponed: undefined,
|
|
||||||
headers: undefined,
|
headers: undefined,
|
||||||
status: undefined,
|
status: undefined,
|
||||||
},
|
},
|
||||||
|
|
|
@ -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_FETCH_URL_HEADER = 'x-vercel-cache-item-name' as const
|
||||||
const CACHE_CONTROL_VALUE_HEADER = 'x-vercel-cache-control' 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 {
|
export default class FetchCache implements CacheHandler {
|
||||||
private headers: Record<string, string>
|
private headers: Record<string, string>
|
||||||
private cacheEndpoint?: string
|
private cacheEndpoint?: string
|
||||||
private debug: boolean
|
|
||||||
|
|
||||||
private hasMatchingTags(arr1: string[], arr2: string[]) {
|
private hasMatchingTags(arr1: string[], arr2: string[]) {
|
||||||
if (arr1.length !== arr2.length) return false
|
if (arr1.length !== arr2.length) return false
|
||||||
|
@ -53,7 +83,6 @@ export default class FetchCache implements CacheHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(ctx: CacheHandlerContext) {
|
constructor(ctx: CacheHandlerContext) {
|
||||||
this.debug = !!process.env.NEXT_PRIVATE_DEBUG_CACHE
|
|
||||||
this.headers = {}
|
this.headers = {}
|
||||||
this.headers['Content-Type'] = 'application/json'
|
this.headers['Content-Type'] = 'application/json'
|
||||||
|
|
||||||
|
@ -79,17 +108,18 @@ export default class FetchCache implements CacheHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scHost) {
|
if (scHost) {
|
||||||
this.cacheEndpoint = `https://${scHost}${scBasePath || ''}`
|
const scProto = process.env.SUSPENSE_CACHE_PROTO || 'https'
|
||||||
if (this.debug) {
|
this.cacheEndpoint = `${scProto}://${scHost}${scBasePath || ''}`
|
||||||
|
if (DEBUG) {
|
||||||
console.log('using cache endpoint', this.cacheEndpoint)
|
console.log('using cache endpoint', this.cacheEndpoint)
|
||||||
}
|
}
|
||||||
} else if (this.debug) {
|
} else if (DEBUG) {
|
||||||
console.log('no cache endpoint available')
|
console.log('no cache endpoint available')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx.maxMemoryCacheSize) {
|
if (ctx.maxMemoryCacheSize) {
|
||||||
if (!memoryCache) {
|
if (!memoryCache) {
|
||||||
if (this.debug) {
|
if (DEBUG) {
|
||||||
console.log('using memory store for fetch cache')
|
console.log('using memory store for fetch cache')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -118,7 +148,7 @@ export default class FetchCache implements CacheHandler {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (this.debug) {
|
if (DEBUG) {
|
||||||
console.log('not using memory store for fetch cache')
|
console.log('not using memory store for fetch cache')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -133,21 +163,21 @@ export default class FetchCache implements CacheHandler {
|
||||||
) {
|
) {
|
||||||
let [tags] = args
|
let [tags] = args
|
||||||
tags = typeof tags === 'string' ? [tags] : tags
|
tags = typeof tags === 'string' ? [tags] : tags
|
||||||
if (this.debug) {
|
if (DEBUG) {
|
||||||
console.log('revalidateTag', tags)
|
console.log('revalidateTag', tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tags.length) return
|
if (!tags.length) return
|
||||||
|
|
||||||
if (Date.now() < rateLimitedUntil) {
|
if (Date.now() < rateLimitedUntil) {
|
||||||
if (this.debug) {
|
if (DEBUG) {
|
||||||
console.log('rate limited ', rateLimitedUntil)
|
console.log('rate limited ', rateLimitedUntil)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetchRetryWithTimeout(
|
||||||
`${this.cacheEndpoint}/v1/suspense-cache/revalidate?tags=${tags
|
`${this.cacheEndpoint}/v1/suspense-cache/revalidate?tags=${tags
|
||||||
.map((tag) => encodeURIComponent(tag))
|
.map((tag) => encodeURIComponent(tag))
|
||||||
.join(',')}`,
|
.join(',')}`,
|
||||||
|
@ -181,7 +211,7 @@ export default class FetchCache implements CacheHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Date.now() < rateLimitedUntil) {
|
if (Date.now() < rateLimitedUntil) {
|
||||||
if (this.debug) {
|
if (DEBUG) {
|
||||||
console.log('rate limited')
|
console.log('rate limited')
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
@ -227,7 +257,7 @@ export default class FetchCache implements CacheHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.status === 404) {
|
if (res.status === 404) {
|
||||||
if (this.debug) {
|
if (DEBUG) {
|
||||||
console.log(
|
console.log(
|
||||||
`no fetch cache entry for ${key}, duration: ${
|
`no fetch cache entry for ${key}, duration: ${
|
||||||
Date.now() - start
|
Date.now() - start
|
||||||
|
@ -245,7 +275,7 @@ export default class FetchCache implements CacheHandler {
|
||||||
const cached: IncrementalCacheValue = await res.json()
|
const cached: IncrementalCacheValue = await res.json()
|
||||||
|
|
||||||
if (!cached || cached.kind !== 'FETCH') {
|
if (!cached || cached.kind !== 'FETCH') {
|
||||||
this.debug && console.log({ cached })
|
DEBUG && console.log({ cached })
|
||||||
throw new Error('invalid cache value')
|
throw new Error('invalid cache value')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -272,7 +302,7 @@ export default class FetchCache implements CacheHandler {
|
||||||
: Date.now() - parseInt(age || '0', 10) * 1000,
|
: Date.now() - parseInt(age || '0', 10) * 1000,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.debug) {
|
if (DEBUG) {
|
||||||
console.log(
|
console.log(
|
||||||
`got fetch cache entry for ${key}, duration: ${
|
`got fetch cache entry for ${key}, duration: ${
|
||||||
Date.now() - start
|
Date.now() - start
|
||||||
|
@ -289,7 +319,7 @@ export default class FetchCache implements CacheHandler {
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// unable to get data from fetch-cache
|
// unable to get data from fetch-cache
|
||||||
if (this.debug) {
|
if (DEBUG) {
|
||||||
console.error(`Failed to get from fetch-cache`, err)
|
console.error(`Failed to get from fetch-cache`, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -314,7 +344,7 @@ export default class FetchCache implements CacheHandler {
|
||||||
JSON.stringify((newValue as Record<string, string | Object>)[field])
|
JSON.stringify((newValue as Record<string, string | Object>)[field])
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
if (this.debug) {
|
if (DEBUG) {
|
||||||
console.log(`skipping cache set for ${key} as not modified`)
|
console.log(`skipping cache set for ${key} as not modified`)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
@ -324,7 +354,7 @@ export default class FetchCache implements CacheHandler {
|
||||||
if (!fetchCache) return
|
if (!fetchCache) return
|
||||||
|
|
||||||
if (Date.now() < rateLimitedUntil) {
|
if (Date.now() < rateLimitedUntil) {
|
||||||
if (this.debug) {
|
if (DEBUG) {
|
||||||
console.log('rate limited')
|
console.log('rate limited')
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
@ -356,7 +386,7 @@ export default class FetchCache implements CacheHandler {
|
||||||
tags: undefined,
|
tags: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (this.debug) {
|
if (DEBUG) {
|
||||||
console.log('set cache', key)
|
console.log('set cache', key)
|
||||||
}
|
}
|
||||||
const fetchParams: NextFetchCacheParams = {
|
const fetchParams: NextFetchCacheParams = {
|
||||||
|
@ -385,11 +415,11 @@ export default class FetchCache implements CacheHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
this.debug && console.log(await res.text())
|
DEBUG && console.log(await res.text())
|
||||||
throw new Error(`invalid response ${res.status}`)
|
throw new Error(`invalid response ${res.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.debug) {
|
if (DEBUG) {
|
||||||
console.log(
|
console.log(
|
||||||
`successfully set to fetch-cache for ${key}, duration: ${
|
`successfully set to fetch-cache for ${key}, duration: ${
|
||||||
Date.now() - start
|
Date.now() - start
|
||||||
|
@ -398,7 +428,7 @@ export default class FetchCache implements CacheHandler {
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// unable to set to fetch-cache
|
// unable to set to fetch-cache
|
||||||
if (this.debug) {
|
if (DEBUG) {
|
||||||
console.error(`Failed to update fetch cache`, err)
|
console.error(`Failed to update fetch cache`, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -113,7 +113,7 @@ export function unstable_cache<T extends Callback>(
|
||||||
return a.localeCompare(b)
|
return a.localeCompare(b)
|
||||||
})
|
})
|
||||||
const sortedSearch = sortedSearchKeys
|
const sortedSearch = sortedSearchKeys
|
||||||
.map((key) => searchParams.get(key))
|
.map((key) => `${key}=${searchParams.get(key)}`)
|
||||||
.join('&')
|
.join('&')
|
||||||
|
|
||||||
// Construct the complete cache key for this function invocation
|
// Construct the complete cache key for this function invocation
|
||||||
|
@ -180,6 +180,7 @@ export function unstable_cache<T extends Callback>(
|
||||||
tags,
|
tags,
|
||||||
softTags: implicitTags,
|
softTags: implicitTags,
|
||||||
fetchIdx,
|
fetchIdx,
|
||||||
|
fetchUrl,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (cacheEntry && cacheEntry.value) {
|
if (cacheEntry && cacheEntry.value) {
|
||||||
|
@ -276,10 +277,17 @@ export function unstable_cache<T extends Callback>(
|
||||||
if (!incrementalCache.isOnDemandRevalidate) {
|
if (!incrementalCache.isOnDemandRevalidate) {
|
||||||
// We aren't doing an on demand revalidation so we check use the cache if valid
|
// 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, {
|
const cacheEntry = await incrementalCache.get(cacheKey, {
|
||||||
kindHint: 'fetch',
|
kindHint: 'fetch',
|
||||||
revalidate: options.revalidate,
|
revalidate: options.revalidate,
|
||||||
tags,
|
tags,
|
||||||
|
fetchIdx,
|
||||||
|
fetchUrl,
|
||||||
|
softTags: implicitTags,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (cacheEntry && cacheEntry.value) {
|
if (cacheEntry && cacheEntry.value) {
|
||||||
|
|
|
@ -1,6 +1,17 @@
|
||||||
{
|
{
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"suites": {
|
"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",
|
||||||
|
"fetch-cache should not retry for failed fetch-cache GET"
|
||||||
|
],
|
||||||
|
"pending": [],
|
||||||
|
"flakey": [],
|
||||||
|
"runtimeError": false
|
||||||
|
},
|
||||||
"test/e2e/app-dir/app-static/app-static.test.ts": {
|
"test/e2e/app-dir/app-static/app-static.test.ts": {
|
||||||
"failed": [
|
"failed": [
|
||||||
"app-dir static/dynamic handling usePathname should have values from canonical url on rewrite",
|
"app-dir static/dynamic handling usePathname should have values from canonical url on rewrite",
|
||||||
|
|
|
@ -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
test/production/app-dir/fetch-cache/app/layout.tsx
Normal file
8
test/production/app-dir/fetch-cache/app/layout.tsx
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
export default function Root({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
32
test/production/app-dir/fetch-cache/app/page.tsx
Normal file
32
test/production/app-dir/fetch-cache/app/page.tsx
Normal file
|
@ -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'], revalidate: 3 } }
|
||||||
|
).then((res) => res.text())
|
||||||
|
|
||||||
|
const cachedRandom = getCachedRandom()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>hello world</p>
|
||||||
|
<p id="data">{data}</p>
|
||||||
|
<p id="random">{cachedRandom}</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
240
test/production/app-dir/fetch-cache/fetch-cache.test.ts
Normal file
240
test/production/app-dir/fetch-cache/fetch-cache.test.ts
Normal file
|
@ -0,0 +1,240 @@
|
||||||
|
import glob from 'glob'
|
||||||
|
import http from 'http'
|
||||||
|
import fs from 'fs-extra'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { FileRef, NextInstance, createNext } from 'e2e-utils'
|
||||||
|
import {
|
||||||
|
retry,
|
||||||
|
killApp,
|
||||||
|
findPort,
|
||||||
|
fetchViaHTTP,
|
||||||
|
initNextServerScript,
|
||||||
|
} from 'next-test-utils'
|
||||||
|
|
||||||
|
describe('fetch-cache', () => {
|
||||||
|
let next: NextInstance
|
||||||
|
let appPort: any
|
||||||
|
let cliOuptut = ''
|
||||||
|
let nextInstance: any
|
||||||
|
let fetchGetReqIndex = 0
|
||||||
|
let revalidateReqIndex = 0
|
||||||
|
let fetchGetShouldError = false
|
||||||
|
let fetchCacheServer: http.Server
|
||||||
|
let fetchCacheRequests: Array<{
|
||||||
|
url: string
|
||||||
|
method: string
|
||||||
|
headers: Record<string, string | string[]>
|
||||||
|
}> = []
|
||||||
|
let fetchCacheEnv: Record<string, string> = {
|
||||||
|
SUSPENSE_CACHE_PROTO: 'http',
|
||||||
|
}
|
||||||
|
|
||||||
|
const setupNext = async ({
|
||||||
|
nextEnv,
|
||||||
|
minimalMode,
|
||||||
|
}: {
|
||||||
|
nextEnv?: boolean
|
||||||
|
minimalMode?: boolean
|
||||||
|
}) => {
|
||||||
|
// test build against environment with next support
|
||||||
|
process.env.NOW_BUILDER = nextEnv ? '1' : ''
|
||||||
|
|
||||||
|
next = await createNext({
|
||||||
|
files: {
|
||||||
|
app: new FileRef(join(__dirname, 'app')),
|
||||||
|
},
|
||||||
|
nextConfig: {
|
||||||
|
eslint: {
|
||||||
|
ignoreDuringBuilds: true,
|
||||||
|
},
|
||||||
|
output: 'standalone',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await next.stop()
|
||||||
|
|
||||||
|
await fs.move(
|
||||||
|
join(next.testDir, '.next/standalone'),
|
||||||
|
join(next.testDir, 'standalone')
|
||||||
|
)
|
||||||
|
for (const file of await fs.readdir(next.testDir)) {
|
||||||
|
if (file !== 'standalone') {
|
||||||
|
await fs.remove(join(next.testDir, file))
|
||||||
|
console.log('removed', file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const files = glob.sync('**/*', {
|
||||||
|
cwd: join(next.testDir, 'standalone/.next/server/pages'),
|
||||||
|
dot: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.endsWith('.json') || file.endsWith('.html')) {
|
||||||
|
await fs.remove(join(next.testDir, '.next/server', file))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testServer = join(next.testDir, 'standalone/server.js')
|
||||||
|
await fs.writeFile(
|
||||||
|
testServer,
|
||||||
|
(await fs.readFile(testServer, 'utf8')).replace(
|
||||||
|
'port:',
|
||||||
|
`minimalMode: ${minimalMode},port:`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
appPort = await findPort()
|
||||||
|
nextInstance = await initNextServerScript(
|
||||||
|
testServer,
|
||||||
|
/- Local:/,
|
||||||
|
{
|
||||||
|
...process.env,
|
||||||
|
...fetchCacheEnv,
|
||||||
|
PORT: appPort,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
cwd: next.testDir,
|
||||||
|
onStderr(data) {
|
||||||
|
cliOuptut += data
|
||||||
|
},
|
||||||
|
onStdout(data) {
|
||||||
|
cliOuptut += data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
fetchGetReqIndex = 0
|
||||||
|
revalidateReqIndex = 0
|
||||||
|
fetchCacheRequests = []
|
||||||
|
fetchGetShouldError = false
|
||||||
|
fetchCacheServer = http.createServer((req, res) => {
|
||||||
|
console.log(`fetch cache request ${req.url} ${req.method}`, req.headers)
|
||||||
|
const parsedUrl = new URL(req.url || '/', 'http://n')
|
||||||
|
|
||||||
|
fetchCacheRequests.push({
|
||||||
|
url: req.url,
|
||||||
|
method: req.method?.toLowerCase(),
|
||||||
|
headers: req.headers,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (parsedUrl.pathname === '/v1/suspense-cache/revalidate') {
|
||||||
|
revalidateReqIndex += 1
|
||||||
|
// timeout unless it's 3rd retry
|
||||||
|
const shouldTimeout = revalidateReqIndex % 3 !== 0
|
||||||
|
|
||||||
|
if (shouldTimeout) {
|
||||||
|
console.log('not responding for', req.url, { revalidateReqIndex })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.statusCode = 200
|
||||||
|
res.end(`revalidated ${parsedUrl.searchParams.get('tags')}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const keyMatches = parsedUrl.pathname.match(
|
||||||
|
/\/v1\/suspense-cache\/(.*?)\/?$/
|
||||||
|
)
|
||||||
|
const key = keyMatches?.[0]
|
||||||
|
|
||||||
|
if (key) {
|
||||||
|
const type = req.method?.toLowerCase()
|
||||||
|
console.log(`got ${type} for ${key}`)
|
||||||
|
|
||||||
|
if (type === 'get') {
|
||||||
|
fetchGetReqIndex += 1
|
||||||
|
|
||||||
|
if (fetchGetShouldError) {
|
||||||
|
res.statusCode = 500
|
||||||
|
res.end('internal server error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.statusCode = type === 'post' ? 200 : 404
|
||||||
|
res.end(`${type} for ${key}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.statusCode = 404
|
||||||
|
res.end('not found')
|
||||||
|
})
|
||||||
|
await new Promise<void>(async (resolve) => {
|
||||||
|
let fetchCachePort = await findPort()
|
||||||
|
fetchCacheServer.listen(fetchCachePort, () => {
|
||||||
|
fetchCacheEnv['SUSPENSE_CACHE_URL'] = `[::]:${fetchCachePort}`
|
||||||
|
console.log(
|
||||||
|
`Started fetch cache server at http://${fetchCacheEnv['SUSPENSE_CACHE_URL']}`
|
||||||
|
)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await setupNext({ nextEnv: true, minimalMode: true })
|
||||||
|
})
|
||||||
|
afterAll(async () => {
|
||||||
|
await next.destroy()
|
||||||
|
if (fetchCacheServer) fetchCacheServer.close()
|
||||||
|
if (nextInstance) await killApp(nextInstance)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have correct fetchUrl field for fetches and unstable_cache', async () => {
|
||||||
|
const res = await fetchViaHTTP(appPort, '/?myKey=myValue')
|
||||||
|
const html = await res.text()
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(html).toContain('hello world')
|
||||||
|
|
||||||
|
const fetchUrlHeader = 'x-vercel-cache-item-name'
|
||||||
|
const fetchTagsHeader = 'x-vercel-cache-tags'
|
||||||
|
const fetchSoftTagsHeader = 'x-next-cache-soft-tags'
|
||||||
|
const unstableCacheSet = fetchCacheRequests.find((item) => {
|
||||||
|
return (
|
||||||
|
item.method === 'get' &&
|
||||||
|
item.headers[fetchUrlHeader]?.includes('unstable_cache')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const fetchSet = fetchCacheRequests.find((item) => {
|
||||||
|
return (
|
||||||
|
item.method === 'get' &&
|
||||||
|
item.headers[fetchUrlHeader]?.includes('next-data-api-endpoint')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(unstableCacheSet.headers[fetchUrlHeader]).toMatch(
|
||||||
|
/unstable_cache \/\?myKey=myValue .*?/
|
||||||
|
)
|
||||||
|
expect(unstableCacheSet.headers[fetchTagsHeader]).toBe('thankyounext')
|
||||||
|
expect(unstableCacheSet.headers[fetchSoftTagsHeader]).toBe(
|
||||||
|
'_N_T_/layout,_N_T_/page,_N_T_/'
|
||||||
|
)
|
||||||
|
expect(fetchSet.headers[fetchUrlHeader]).toBe(
|
||||||
|
'https://next-data-api-endpoint.vercel.app/api/random?a=b'
|
||||||
|
)
|
||||||
|
expect(fetchSet.headers[fetchSoftTagsHeader]).toBe(
|
||||||
|
'_N_T_/layout,_N_T_/page,_N_T_/'
|
||||||
|
)
|
||||||
|
expect(fetchSet.headers[fetchTagsHeader]).toBe('thankyounext')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should retry 3 times when revalidate times out', async () => {
|
||||||
|
await fetchViaHTTP(appPort, '/api/revalidate')
|
||||||
|
|
||||||
|
await retry(() => {
|
||||||
|
expect(revalidateReqIndex).toBe(3)
|
||||||
|
})
|
||||||
|
expect(cliOuptut).not.toContain('Failed to revalidate')
|
||||||
|
expect(cliOuptut).not.toContain('Error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not retry for failed fetch-cache GET', async () => {
|
||||||
|
fetchGetShouldError = true
|
||||||
|
const fetchGetReqIndexStart = fetchGetReqIndex
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchViaHTTP(appPort, '/api/revalidate')
|
||||||
|
const res = await fetchViaHTTP(appPort, '/')
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.text()).toContain('hello world')
|
||||||
|
expect(fetchGetReqIndex).toBe(fetchGetReqIndexStart + 2)
|
||||||
|
} finally {
|
||||||
|
fetchGetShouldError = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
|
@ -1,6 +1,17 @@
|
||||||
{
|
{
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"suites": {
|
"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",
|
||||||
|
"fetch-cache should not retry for failed fetch-cache GET"
|
||||||
|
],
|
||||||
|
"pending": [],
|
||||||
|
"flakey": [],
|
||||||
|
"runtimeError": false
|
||||||
|
},
|
||||||
"test/e2e/404-page-router/index.test.ts": {
|
"test/e2e/404-page-router/index.test.ts": {
|
||||||
"passed": [
|
"passed": [
|
||||||
"404-page-router 404-page-router with basePath of false and i18n of false and middleware false for /error should have the correct router parameters after it is ready",
|
"404-page-router 404-page-router with basePath of false and i18n of false and middleware false for /error should have the correct router parameters after it is ready",
|
||||||
|
|
Loading…
Reference in a new issue