Skip to content

Commit 56eac9b

Browse files
Server side query retry and caching with request scoped QueryClient (#2596)
* Server side query retry and caching with request scoped QueryClient and native fetch for axios * Default to force-cache on fetch options. ESLint rule to prevent use of API outside a Query Client * Status on axios error * Update comment * Hydration issues with non-serializable error response. Sanitize axios errors and remove from query client * Remove catch - prefetch does not throw * Safe generate metadata utility with error catch * Remove use of native fetch * Update tests to expect console calls * Remove comment * Update comment Co-authored-by: Chris Chudzicki <christopher.chudzicki@gmail.com> * Update to use query client for API calls / no direct API client import * Metadata fallback not used * Remove not used debug props. Update test for standardized metadata fallback --------- Co-authored-by: Chris Chudzicki <christopher.chudzicki@gmail.com>
1 parent 5ac5dbf commit 56eac9b

File tree

28 files changed

+369
-298
lines changed

28 files changed

+369
-298
lines changed

frontends/.eslintrc.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@ module.exports = {
6868
message:
6969
"The LearningResourceDrawer should be lazy loaded with dynamic import.",
7070
},
71+
{
72+
group: ["api/clients"],
73+
message:
74+
"Direct import from 'api/clients' is not allowed. Use React Query hooks from 'api/hooks/*' instead for caching and error handling. In server components, use getServerQueryClient().fetchQuery(), passing the queryOptions.",
75+
},
7176
],
7277
}),
7378
// This rule is disabled in the default a11y config, but unclear why.
@@ -145,6 +150,12 @@ module.exports = {
145150
message:
146151
"Do not specify `fontFamily` manually. Prefer spreading `theme.typography.subtitle1` or similar. If using neue-haas-grotesk-text, this is ThemeProvider's default fontFamily.",
147152
},
153+
{
154+
selector:
155+
"FunctionDeclaration[id.name='generateMetadata'] > BlockStatement > ReturnStatement[argument.type!='CallExpression'], FunctionDeclaration[id.name='generateMetadata'] > BlockStatement > ReturnStatement[argument.callee.name!='safeGenerateMetadata']",
156+
message:
157+
"generateMetadata functions must return safeGenerateMetadata() to ensure proper error handling and fallback metadata.",
158+
},
148159
],
149160
},
150161
overrides: [

frontends/api/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,13 @@
55
"sideEffects": false,
66
"exports": {
77
".": "./src/generated/v1/api.ts",
8-
"./clients": "./src/clients.ts",
98
"./v0": "./src/generated/v0/api.ts",
109
"./v1": "./src/generated/v1/api.ts",
1110
"./hooks/*": "./src/hooks/*/index.ts",
1211
"./constants": "./src/common/constants.ts",
1312
"./ssr/*": "./src/ssr/*.ts",
1413
"./test-utils/factories": "./src/test-utils/factories/index.ts",
1514
"./test-utils": "./src/test-utils/index.ts",
16-
"./mitxonline": "./src/mitxonline/index.ts",
1715
"./mitxonline-hooks/*": "./src/mitxonline/hooks/*/index.ts",
1816
"./mitxonline-test-utils": "./src/mitxonline/test-utils/index.ts"
1917
},

frontends/api/src/hooks/learningResources/queries.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type {
1818
FeaturedApiFeaturedListRequest as FeaturedListParams,
1919
LearningResourcesApiLearningResourcesItemsListRequest as ItemsListRequest,
2020
LearningResourcesSearchResponse,
21+
LearningResourcesApiLearningResourcesSummaryListRequest as LearningResourcesSummaryListRequest,
2122
} from "../../generated/v1"
2223
import { queryOptions } from "@tanstack/react-query"
2324
import { hasPosition, randomizeGroups } from "./util"
@@ -46,6 +47,11 @@ const learningResourceKeys = {
4647
...learningResourceKeys.listsRoot(),
4748
params,
4849
],
50+
summaryListRoot: () => [...learningResourceKeys.root, "summaryList"],
51+
summaryList: (params: LearningResourcesSummaryListRequest) => [
52+
...learningResourceKeys.summaryListRoot(),
53+
params,
54+
],
4955
// detail
5056
detailsRoot: () => [...learningResourceKeys.root, "detail"],
5157
detail: (id: number) => [...learningResourceKeys.detailsRoot(), id],
@@ -150,6 +156,14 @@ const learningResourceQueries = {
150156
results: res.data.results.map(clearListMemberships),
151157
})),
152158
}),
159+
summaryList: (params: LearningResourcesSummaryListRequest) =>
160+
queryOptions({
161+
queryKey: learningResourceKeys.summaryList(params),
162+
queryFn: () =>
163+
learningResourcesApi
164+
.learningResourcesSummaryList(params)
165+
.then((res) => res.data),
166+
}),
153167
featured: (params: FeaturedListParams = {}) =>
154168
queryOptions({
155169
queryKey: learningResourceKeys.featured(params),

frontends/api/src/mitxonline/index.ts

Lines changed: 0 additions & 1 deletion
This file was deleted.

frontends/api/src/ssr/prefetch.ts

Lines changed: 15 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,31 @@
1-
import { QueryClient, dehydrate } from "@tanstack/react-query"
1+
import { dehydrate } from "@tanstack/react-query"
2+
import { getServerQueryClient } from "./serverQueryClient"
23
import type { Query } from "@tanstack/react-query"
34

4-
const MAX_RETRIES = 3
5-
const NO_RETRY_CODES = [400, 401, 403, 404, 405, 409, 422]
6-
7-
type MaybeHasStatus = {
8-
response?: {
9-
status?: number
10-
}
11-
}
12-
13-
const getPrefetchQueryClient = () => {
14-
return new QueryClient({
15-
defaultOptions: {
16-
queries: {
17-
/**
18-
* React Query's default retry logic is only active in the browser.
19-
* Here we explicitly configure it to retry MAX_RETRIES times on
20-
* the server, with an exclusion list of statuses that we expect not
21-
* to succeed on retry.
22-
*
23-
* Includes status undefined as we want to retry on network errors
24-
*/
25-
retry: (failureCount, error) => {
26-
const status = (error as MaybeHasStatus)?.response?.status
27-
const isNetworkError = status === undefined || status === 0
28-
29-
if (isNetworkError || !NO_RETRY_CODES.includes(status)) {
30-
return failureCount < MAX_RETRIES
31-
}
32-
return false
33-
},
34-
35-
/**
36-
* By default, React Query gradually applies a backoff delay, though it is
37-
* preferable that we do not significantly delay initial page renders (or
38-
* indeed pages that are Statically Rendered during the build process) and
39-
* instead allow the request to fail quickly so it can be subsequently
40-
* fetched on the client.
41-
*/
42-
retryDelay: 1000,
43-
},
44-
},
45-
})
46-
}
47-
48-
/* Utility to avoid repetition in server components
5+
/**
6+
* Utility to avoid repetition in server components
497
* Optionally pass the queryClient returned from a previous prefetch
508
* where queries are dependent on previous results
519
*/
5210
export const prefetch = async (
5311
queries: (Query | unknown)[],
5412

5513
/**
56-
* The SSR QueryClient is transient - it is created only for prefetch
57-
* while API requests are made to server render the page and discarded
58-
* as the dehydrated state is produced and sent to the client.
14+
* Unless passed, the SSR QueryClient uses React's cache() for reuse for the duration of the request.
5915
*
60-
* Create a new query client if one is not provided.
16+
* The QueryClient is garbage collected once the dehydrated state is produced and
17+
* sent to the client and the request is complete.
6118
*/
62-
queryClient = getPrefetchQueryClient(),
19+
queryClient = getServerQueryClient(),
6320
) => {
6421
await Promise.all(
65-
queries
66-
.filter(Boolean)
67-
.map((query) => queryClient.prefetchQuery(query as Query)),
22+
queries.filter(Boolean).map((query) => {
23+
return queryClient.prefetchQuery(query as Query)
24+
}),
6825
)
6926

70-
return { dehydratedState: dehydrate(queryClient), queryClient }
27+
return {
28+
dehydratedState: dehydrate(queryClient),
29+
queryClient,
30+
}
7131
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { cache } from "react"
2+
import { QueryClient } from "@tanstack/react-query"
3+
import { AxiosError } from "axios"
4+
5+
const MAX_RETRIES = 3
6+
const NO_RETRY_CODES = [400, 401, 403, 404, 405, 409, 422]
7+
8+
/**
9+
* Get or create a server-side QueryClient for consistent retry behavior.
10+
* The server QueryClient should be used for all server-side API calls.
11+
*
12+
* Uses React's cache() to ensure the same QueryClient instance is reused
13+
* throughout a single HTTP request, enabling:
14+
*
15+
* - Server API calls share the same QueryClient:
16+
* - Prefetch runs in page server components
17+
* - generateMetadata()
18+
* - No duplicate API calls within the same request
19+
* - Automatic cleanup when the request completes
20+
* - Isolation between different HTTP requests
21+
*
22+
* The QueryClientProvider runs (during SSR) in a separate render pass as it's a
23+
* client component and so the instance is not reused. On the server this does not
24+
* make API calls and only sets up the hydration boundary and registers hooks in
25+
* readiness for the dehydrated state to be sent to the client.
26+
*/
27+
const getServerQueryClient = cache(() => {
28+
const queryClient = new QueryClient({
29+
defaultOptions: {
30+
queries: {
31+
/**
32+
* React Query's default retry logic is only active in the browser.
33+
* Here we explicitly configure it to retry MAX_RETRIES times on
34+
* the server, with an exclusion list of statuses that we expect not
35+
* to succeed on retry.
36+
*
37+
* Includes status undefined as we want to retry on network errors
38+
*/
39+
retry: (failureCount, error) => {
40+
const axiosError = error as AxiosError
41+
console.info("Retrying failed request", {
42+
failureCount,
43+
error: {
44+
message: axiosError.message,
45+
name: axiosError.name,
46+
status: axiosError?.status,
47+
code: axiosError.code,
48+
method: axiosError.request?.method,
49+
url: axiosError.request?.url,
50+
},
51+
})
52+
const status = (error as AxiosError)?.response?.status
53+
const isNetworkError = status === undefined || status === 0
54+
55+
if (isNetworkError || !NO_RETRY_CODES.includes(status)) {
56+
return failureCount < MAX_RETRIES
57+
}
58+
return false
59+
},
60+
61+
/**
62+
* By default, React Query gradually applies a backoff delay, though it is
63+
* preferable that we do not significantly delay initial page renders (or
64+
* indeed pages that are Statically Rendered during the build process) and
65+
* instead allow the request to fail quickly so it can be subsequently
66+
* fetched on the client.
67+
*/
68+
retryDelay: 1000,
69+
},
70+
},
71+
})
72+
73+
return queryClient
74+
})
75+
76+
export { getServerQueryClient }

frontends/main/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"@sentry/nextjs": "^10.0.0",
2323
"@tanstack/react-query": "^5.66",
2424
"api": "workspace:*",
25+
"async_hooks": "^1.0.0",
2526
"classnames": "^2.5.1",
2627
"formik": "^2.4.6",
2728
"iso-639-1": "^3.1.4",

frontends/main/src/app/(products)/courses/[readable_id]/page.tsx

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,30 @@
11
import React from "react"
22
import { HydrationBoundary } from "@tanstack/react-query"
3-
import { standardizeMetadata } from "@/common/metadata"
3+
import { safeGenerateMetadata, standardizeMetadata } from "@/common/metadata"
44
import { prefetch } from "api/ssr/prefetch"
55
import CoursePage from "@/app-pages/ProductPages/CoursePage"
6-
import { pagesApi } from "api/mitxonline"
7-
import * as Sentry from "@sentry/nextjs"
86
import { notFound } from "next/navigation"
97
import { pagesQueries } from "api/mitxonline-hooks/pages"
108
import { coursesQueries } from "api/mitxonline-hooks/courses"
119
import { DEFAULT_RESOURCE_IMG } from "ol-utilities"
10+
import { getServerQueryClient } from "api/ssr/serverQueryClient"
1211

1312
export const generateMetadata = async (
1413
props: PageProps<"/courses/[readable_id]">,
1514
) => {
1615
const params = await props.params
1716

18-
try {
19-
const resp = await pagesApi.pagesfieldstypecmsCoursepageRetrieve({
20-
readable_id: decodeURIComponent(params.readable_id),
21-
})
17+
return safeGenerateMetadata(async () => {
18+
const queryClient = getServerQueryClient()
19+
20+
const data = await queryClient.fetchQuery(
21+
pagesQueries.coursePages(decodeURIComponent(params.readable_id)),
22+
)
2223

23-
if (resp.data.items.length === 0) {
24+
if (data.items.length === 0) {
2425
notFound()
2526
}
26-
const [course] = resp.data.items
27+
const [course] = data.items
2728
const image = course.feature_image
2829
? course.course_details.page.feature_image_src
2930
: DEFAULT_RESOURCE_IMG
@@ -32,14 +33,7 @@ export const generateMetadata = async (
3233
image,
3334
robots: "noindex, nofollow",
3435
})
35-
} catch (error) {
36-
Sentry.captureException(error)
37-
console.error(
38-
"Error fetching course page metadata for",
39-
params.readable_id,
40-
error,
41-
)
42-
}
36+
})
4337
}
4438

4539
const Page: React.FC<PageProps<"/courses/[readable_id]">> = async (props) => {

frontends/main/src/app/(products)/programs/[readable_id]/page.tsx

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,30 @@
11
import React from "react"
22
import { HydrationBoundary } from "@tanstack/react-query"
3-
import { standardizeMetadata } from "@/common/metadata"
3+
import { safeGenerateMetadata, standardizeMetadata } from "@/common/metadata"
44
import { prefetch } from "api/ssr/prefetch"
55
import ProgramPage from "@/app-pages/ProductPages/ProgramPage"
6-
import { pagesApi } from "api/mitxonline"
7-
import * as Sentry from "@sentry/nextjs"
86
import { notFound } from "next/navigation"
97
import { pagesQueries } from "api/mitxonline-hooks/pages"
108
import { programsQueries } from "api/mitxonline-hooks/programs"
119
import { DEFAULT_RESOURCE_IMG } from "ol-utilities"
10+
import { getServerQueryClient } from "api/ssr/serverQueryClient"
1211

1312
export const generateMetadata = async (
1413
props: PageProps<"/programs/[readable_id]">,
1514
) => {
1615
const params = await props.params
1716

18-
try {
19-
const resp = await pagesApi.pagesfieldstypecmsProgrampageRetrieve({
20-
readable_id: decodeURIComponent(params.readable_id),
21-
})
17+
return safeGenerateMetadata(async () => {
18+
const queryClient = getServerQueryClient()
19+
20+
const data = await queryClient.fetchQuery(
21+
pagesQueries.programPages(decodeURIComponent(params.readable_id)),
22+
)
2223

23-
if (resp.data.items.length === 0) {
24+
if (data.items.length === 0) {
2425
notFound()
2526
}
26-
const [program] = resp.data.items
27+
const [program] = data.items
2728

2829
// Note: feature_image.src is relative to mitxonline root.
2930
const image = program.feature_image
@@ -34,17 +35,10 @@ export const generateMetadata = async (
3435
image,
3536
robots: "noindex, nofollow",
3637
})
37-
} catch (error) {
38-
Sentry.captureException(error)
39-
console.error(
40-
"Error fetching course page metadata for",
41-
params.readable_id,
42-
error,
43-
)
44-
}
38+
})
4539
}
4640

47-
const Page: React.FC<PageProps<"/courses/[readable_id]">> = async (props) => {
41+
const Page: React.FC<PageProps<"/programs/[readable_id]">> = async (props) => {
4842
const params = await props.params
4943
const readableId = decodeURIComponent(params.readable_id)
5044
/**

0 commit comments

Comments
 (0)