Skip to content

Commit 4393139

Browse files
authored
Retry requests on connection errors and additional behavior (#2230)
* wip * Comments and clean up * Fixed retry delay, additional no-retry codes, comments, clean up * Catch errors fetching page metadata * Move retry delay to the SSR prefetch query client * Fixed retry delay on the server. Update test. * 3 retries * Refactor for query client
1 parent cc3f865 commit 4393139

File tree

5 files changed

+118
-33
lines changed

5 files changed

+118
-33
lines changed

frontends/api/src/ssr/prefetch.ts

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,66 @@
11
import { QueryClient, dehydrate } from "@tanstack/react-query"
22
import type { Query } from "@tanstack/react-query"
33

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+
448
/* Utility to avoid repetition in server components
549
* Optionally pass the queryClient returned from a previous prefetch
650
* where queries are dependent on previous results
751
*/
852
export const prefetch = async (
953
queries: (Query | unknown)[],
10-
queryClient?: QueryClient,
11-
) => {
12-
queryClient = queryClient || new QueryClient()
1354

55+
/**
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.
59+
*
60+
* Create a new query client if one is not provided.
61+
*/
62+
queryClient = getPrefetchQueryClient(),
63+
) => {
1464
await Promise.all(
1565
queries
1666
.filter(Boolean)

frontends/main/src/app/c/[channelType]/[name]/page.tsx

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -71,17 +71,18 @@ const Page: React.FC = async ({
7171
const channel = queryClient.getQueryData<UnitChannel>(
7272
channelQueries.detailByType(channelType, name).queryKey,
7373
)
74-
const offerors = queryClient
75-
.getQueryData<PaginatedLearningResourceOfferorDetailList>(
76-
offerorQueries.list({}).queryKey,
77-
)!
78-
.results.reduce(
79-
(memo, offeror) => ({
80-
...memo,
81-
[offeror.code]: offeror,
82-
}),
83-
[],
84-
)
74+
const offerors =
75+
queryClient
76+
.getQueryData<PaginatedLearningResourceOfferorDetailList>(
77+
offerorQueries.list({}).queryKey,
78+
)
79+
?.results.reduce(
80+
(memo, offeror) => ({
81+
...memo,
82+
[offeror.code]: offeror,
83+
}),
84+
[],
85+
) ?? {}
8586

8687
const constantSearchParams = getConstantSearchParams(channel?.search_filter)
8788

frontends/main/src/app/getQueryClient.test.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@ const getWrapper = () => {
1313
}
1414

1515
test.each([
16+
{ status: 0, retries: 3 },
17+
{ status: 404, retries: 0 },
18+
{ status: 405, retries: 0 },
1619
{ status: 408, retries: 3 },
20+
{ status: 409, retries: 0 },
21+
{ status: 422, retries: 0 },
1722
{ status: 429, retries: 3 },
1823
{ status: 502, retries: 3 },
1924
{ status: 503, retries: 3 },
@@ -24,6 +29,7 @@ test.each([
2429
allowConsoleErrors()
2530
const wrapper = getWrapper()
2631
const queryFn = jest.fn().mockRejectedValue({ response: { status } })
32+
2733
const { result } = renderHook(
2834
() =>
2935
useQuery({

frontends/main/src/app/getQueryClient.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,36 +8,54 @@ type MaybeHasStatus = {
88
}
99
}
1010

11-
const RETRY_STATUS_CODES = [408, 429, 502, 503, 504]
1211
const MAX_RETRIES = 3
13-
const THROW_ERROR_CODES: (number | undefined)[] = [404, 403, 401]
12+
const THROW_ERROR_CODES = [400, 401, 403]
13+
const NO_RETRY_CODES = [400, 401, 403, 404, 405, 409, 422]
1414

1515
const makeQueryClient = (): QueryClient => {
1616
return new QueryClient({
1717
defaultOptions: {
1818
queries: {
1919
refetchOnWindowFocus: false,
2020
staleTime: Infinity,
21-
// Throw runtime errors instead of marking query as errored.
22-
// The runtime error will be caught by an error boundary.
23-
// For now, only do this for 404s, 403s, and 401s. Other errors should
24-
// be handled locally by components.
21+
22+
/**
23+
* Throw runtime errors instead of marking query as errored.
24+
* The runtime error will be caught by an error boundary.
25+
* For now, only do this for 404s, 403s, and 401s. Other errors should
26+
* be handled locally by components.
27+
*/
2528
throwOnError: (error) => {
2629
const status = (error as MaybeHasStatus)?.response?.status
27-
return THROW_ERROR_CODES.includes(status)
30+
return THROW_ERROR_CODES.includes(status ?? 0)
2831
},
32+
2933
retry: (failureCount, error) => {
3034
const status = (error as MaybeHasStatus)?.response?.status
35+
const isNetworkError = status === undefined || status === 0
36+
3137
/**
3238
* React Query's default behavior is to retry all failed queries 3
33-
* times. Many things (e.g., 403, 404) are not worth retrying. Let's
34-
* just retry some explicit whitelist of status codes.
39+
* times. Many things (e.g. 403, 404) are not worth retrying. Let's
40+
* exclude these.
41+
*
42+
* Includes statuses undefined and 0 as we want to retry on network errors.
3543
*/
36-
if (status !== undefined && RETRY_STATUS_CODES.includes(status)) {
44+
if (isNetworkError || !NO_RETRY_CODES.includes(status)) {
3745
return failureCount < MAX_RETRIES
3846
}
3947
return false
4048
},
49+
50+
/**
51+
* By default, React Query gradually applies a backoff delay, though it is
52+
* preferable that we do not significantly delay initial page renders on
53+
* the server and instead allow the request to fail quickly so it can be
54+
* subsequently fetched on the client. Note that we aim to prefetch any API
55+
* content needed to render the page, so we don't generally expect the retry
56+
* rules above to be in use on the server.
57+
*/
58+
retryDelay: isServer ? 1000 : undefined,
4159
},
4260
},
4361
})

frontends/main/src/common/metadata.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { RESOURCE_DRAWER_PARAMS } from "@/common/urls"
22
import { learningResourcesApi } from "api/clients"
33
import type { Metadata } from "next"
4+
import * as Sentry from "@sentry/nextjs"
45
import handleNotFound from "./handleNotFound"
56

67
const DEFAULT_OG_IMAGE = "/images/learn-og-image.jpg"
@@ -32,16 +33,25 @@ export const getMetadataAsync = async ({
3233
RESOURCE_DRAWER_PARAMS.resource
3334
]
3435
if (learningResourceId) {
35-
const { data } = await handleNotFound(
36-
learningResourcesApi.learningResourcesRetrieve({
37-
id: Number(learningResourceId),
38-
}),
39-
)
36+
try {
37+
const { data } = await handleNotFound(
38+
learningResourcesApi.learningResourcesRetrieve({
39+
id: Number(learningResourceId),
40+
}),
41+
)
4042

41-
title = data?.title
42-
description = data?.description?.replace(/<\/[^>]+(>|$)/g, "") ?? ""
43-
image = data?.image?.url || image
44-
imageAlt = image === data?.image?.url ? imageAlt : data?.image?.alt || ""
43+
title = data?.title
44+
description = data?.description?.replace(/<\/[^>]+(>|$)/g, "") ?? ""
45+
image = data?.image?.url || image
46+
imageAlt = image === data?.image?.url ? imageAlt : data?.image?.alt || ""
47+
} catch (error) {
48+
Sentry.captureException(error)
49+
console.error(
50+
"Error fetching learning resource for page metadata",
51+
learningResourceId,
52+
error,
53+
)
54+
}
4555
}
4656

4757
return standardizeMetadata({

0 commit comments

Comments
 (0)