Skip to content

Commit c9a51ee

Browse files
feat: retry failed aichat requests (#110)
1 parent 3f14a35 commit c9a51ee

File tree

6 files changed

+112
-2
lines changed

6 files changed

+112
-2
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
"@mui/utils": "^6.1.6",
6868
"better-react-mathjax": "^2.3.0",
6969
"classnames": "^2.5.1",
70+
"fetch-retry": "^6.0.0",
7071
"lodash": "^4.17.21",
7172
"react-markdown": "^10.0.0",
7273
"rehype-mathjax": "^7.1.0",

src/components/AiChat/AiChatContext.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@ import * as React from "react"
22
import { useChat, UseChatHelpers } from "@ai-sdk/react"
33
import type { RequestOpts, AiChatMessage, AiChatContextProps } from "./types"
44
import { useMemo, createContext } from "react"
5+
import retryingFetch from "../../utils/retryingFetch"
56

67
const identity = <T,>(x: T): T => x
78

89
const getFetcher: (requestOpts: RequestOpts) => typeof fetch =
910
(requestOpts: RequestOpts) => async (url, opts) => {
1011
if (typeof opts?.body !== "string") {
1112
console.error("Unexpected body type.")
12-
return window.fetch(url, opts)
13+
return retryingFetch(url, opts)
1314
}
1415
const messages: AiChatMessage[] = JSON.parse(opts?.body).messages
1516
const transformBody: RequestOpts["transformBody"] =
@@ -24,7 +25,7 @@ const getFetcher: (requestOpts: RequestOpts) => typeof fetch =
2425
...requestOpts.fetchOpts?.headers,
2526
},
2627
}
27-
return fetch(url, options)
28+
return retryingFetch(url, options)
2829
}
2930

3031
/**

src/components/AiChat/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,11 @@ type AiChatDisplayProps = {
100100
* Defaults to false.
101101
*/
102102
useMathJax?: boolean
103+
/**
104+
* If true, the chat input will be autofocused on load.
105+
* Defaults to true.
106+
*/
107+
autofocus?: boolean
103108
} & RefAttributes<HTMLDivElement>
104109

105110
type AiChatProps = AiChatContextProps & AiChatDisplayProps

src/utils/retryingFetch.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import retryingFetch from "./retryingFetch"
2+
import { http, HttpResponse } from "msw"
3+
import { setupServer } from "msw/node"
4+
5+
const counter = jest.fn() // use jest.fn as counter because it resets on each test
6+
const NETWORK_SUCCESS_URL = "http://localhost:3456/success"
7+
const NETWORK_ERROR_URL = "http://localhost:3456/error"
8+
const server = setupServer(
9+
http.get(NETWORK_SUCCESS_URL, async ({ request }) => {
10+
counter()
11+
const url = new URL(request.url)
12+
const status = +(url.searchParams.get("status") ?? 200)
13+
return HttpResponse.text(`Status ${status}`, { status })
14+
}),
15+
http.get(NETWORK_ERROR_URL, async () => {
16+
counter()
17+
return HttpResponse.error()
18+
}),
19+
)
20+
beforeAll(() => server.listen())
21+
afterEach(() => server.resetHandlers())
22+
afterAll(() => server.close())
23+
24+
describe("retryingFetch", () => {
25+
beforeAll(() => {})
26+
27+
test.each([200, 201, 202, 367, 400, 401, 403])(
28+
"should not retry on %s",
29+
async (status) => {
30+
const result = await retryingFetch(
31+
`${NETWORK_SUCCESS_URL}?status=${status}`,
32+
)
33+
expect(await result.text()).toBe(`Status ${status}`)
34+
expect(counter).toHaveBeenCalledTimes(1)
35+
},
36+
)
37+
38+
test.each([429, 500, 501, 502, 503])("should retry on %s", async (status) => {
39+
const result = await retryingFetch(
40+
`${NETWORK_SUCCESS_URL}?status=${status}`,
41+
)
42+
expect(await result.text()).toBe(`Status ${status}`)
43+
expect(counter).toHaveBeenCalledTimes(4)
44+
})
45+
46+
test("should retry on error", async () => {
47+
const result = await retryingFetch(NETWORK_ERROR_URL).catch((err) => err)
48+
expect(result).toBeInstanceOf(Error)
49+
expect(counter).toHaveBeenCalledTimes(4)
50+
})
51+
})

src/utils/retryingFetch.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import fetchRetrier from "fetch-retry"
2+
3+
const MAX_RETRIES = 3
4+
const NO_RETRY_ERROR_CODES = [400, 401, 403, 404, 405, 409, 422]
5+
const BASE_RETRY_DELAY = process.env.NODE_ENV === "test" ? 10 : 1000
6+
7+
// This is a workaround to ensure retryingFetch uses MSW's fetch version in tests.
8+
// See https://github.com/jonbern/fetch-retry/issues/95#issuecomment-2613990480
9+
const fetch: typeof window.fetch = (...args) => window.fetch(...args)
10+
11+
/**
12+
* A wrapper around fetch that retries the request on certain error codes.
13+
*
14+
* By default:
15+
*
16+
* Requests will be retried if max retries not exceeded and :
17+
* - The status code is >= 400 AND NOT in the NO_RETRY_ERROR_CODES list,
18+
* - OR the request promise rejected (network error)
19+
*
20+
* The retry delay is exponential, 1s, 2s, 4s, 8s, ... maxing at 30s.
21+
*
22+
* Maximum retries is 3.
23+
*
24+
* NOTE:
25+
* - When NODE_ENV="test", the maximum retries is set 0 by default but can be
26+
* set via the TEST_ENV_MAX_RETRIES environment variable.
27+
*/
28+
const retryingFetch = fetchRetrier(fetch, {
29+
retryDelay: (attempt, _error, _response) => {
30+
return Math.min(BASE_RETRY_DELAY * 2 ** attempt, 30_000)
31+
},
32+
retryOn: (attempt, _error, response) => {
33+
if (attempt >= MAX_RETRIES) {
34+
return false
35+
}
36+
if (response) {
37+
if (response.status < 400) return false
38+
return !NO_RETRY_ERROR_CODES.includes(response.status)
39+
}
40+
return true
41+
},
42+
})
43+
44+
export default retryingFetch

yarn.lock

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2834,6 +2834,7 @@ __metadata:
28342834
eslint-plugin-react-hooks: "npm:^5.0.0"
28352835
eslint-plugin-styled-components-a11y: "npm:^2.1.35"
28362836
eslint-plugin-testing-library: "npm:^7.0.0"
2837+
fetch-retry: "npm:^6.0.0"
28372838
jest: "npm:^29.7.0"
28382839
jest-environment-jsdom: "npm:^29.5.0"
28392840
jest-extended: "npm:^4.0.2"
@@ -9229,6 +9230,13 @@ __metadata:
92299230
languageName: node
92309231
linkType: hard
92319232

9233+
"fetch-retry@npm:^6.0.0":
9234+
version: 6.0.0
9235+
resolution: "fetch-retry@npm:6.0.0"
9236+
checksum: 10/0c8d3082e2d76fff2df75adef6280bc854bc36fd3ef38506674f0216d0d819e2efd14da7477d3f1732415aea1d2cfde7cd3e1aeae46f45f2adbfc5133296e8de
9237+
languageName: node
9238+
linkType: hard
9239+
92329240
"figures@npm:^2.0.0":
92339241
version: 2.0.0
92349242
resolution: "figures@npm:2.0.0"

0 commit comments

Comments
 (0)