Repository: mitodl/smoot-design
Pull request #110: feat: retry failed aichat requests
Author: login: ChristopherChudzicki
Labels: name: Needs Review
description: An open Pull Request that is ready for review
name: released
Created: 2025-05-08T15:55:21Z | Merged: 2025-05-08T20:58:48Z

### What are the relevant tickets?
Closes https://github.com/mitodl/hq/issues/7238

### Description (What does it do?)
This PR enables retries on failed AiChat requests. Requests are retried 3 times with exponential backoff, 1s, 2s, 4s.

### Screenshots (if appropriate):
<img width="1205" alt="Screenshot 2025-05-08 at 11 52 03 AM" src="https://github.com/user-attachments/assets/08e6c3c0-1fb9-4e07-8271-578bc356455d" />


### How can this be tested?
The easiest way to test this is illustrated in the screenshot above:

1. `yarn start` and navigate to http://localhost:6006/?path=/docs/smoot-design-ai-aichat--docs
2. Open network tab.
3. Pick an AiChat story and chat with it.
4. In network tab, block the chat requests—this will force them to fail and is an easy way to cause network errors.
5. Send another message. You should see it fail, then see it retried 3 times.




Diff:
diff --git a/package.json b/package.json
index f0cb8e96..89ca1689 100644
--- a/package.json
+++ b/package.json
@@ -67,6 +67,7 @@
     "@mui/utils": "^6.1.6",
     "better-react-mathjax": "^2.3.0",
     "classnames": "^2.5.1",
+    "fetch-retry": "^6.0.0",
     "lodash": "^4.17.21",
     "react-markdown": "^10.0.0",
     "rehype-mathjax": "^7.1.0",
diff --git a/src/components/AiChat/AiChatContext.tsx b/src/components/AiChat/AiChatContext.tsx
index 5ea59372..f6127420 100644
--- a/src/components/AiChat/AiChatContext.tsx
+++ b/src/components/AiChat/AiChatContext.tsx
@@ -2,6 +2,7 @@ import * as React from "react"
 import { useChat, UseChatHelpers } from "@ai-sdk/react"
 import type { RequestOpts, AiChatMessage, AiChatContextProps } from "./types"
 import { useMemo, createContext } from "react"
+import retryingFetch from "../../utils/retryingFetch"
 
 const identity = <T,>(x: T): T => x
 
@@ -9,7 +10,7 @@ const getFetcher: (requestOpts: RequestOpts) => typeof fetch =
   (requestOpts: RequestOpts) => async (url, opts) => {
     if (typeof opts?.body !== "string") {
       console.error("Unexpected body type.")
-      return window.fetch(url, opts)
+      return retryingFetch(url, opts)
     }
     const messages: AiChatMessage[] = JSON.parse(opts?.body).messages
     const transformBody: RequestOpts["transformBody"] =
@@ -24,7 +25,7 @@ const getFetcher: (requestOpts: RequestOpts) => typeof fetch =
         ...requestOpts.fetchOpts?.headers,
       },
     }
-    return fetch(url, options)
+    return retryingFetch(url, options)
   }
 
 /**
diff --git a/src/components/AiChat/types.ts b/src/components/AiChat/types.ts
index e8fd6333..6f8a4dc6 100644
--- a/src/components/AiChat/types.ts
+++ b/src/components/AiChat/types.ts
@@ -100,6 +100,11 @@ type AiChatDisplayProps = {
    * Defaults to false.
    */
   useMathJax?: boolean
+  /**
+   * If true, the chat input will be autofocused on load.
+   * Defaults to true.
+   */
+  autofocus?: boolean
 } & RefAttributes<HTMLDivElement>
 
 type AiChatProps = AiChatContextProps & AiChatDisplayProps
diff --git a/src/utils/retryingFetch.test.ts b/src/utils/retryingFetch.test.ts
new file mode 100644
index 00000000..4fbcb13a
--- /dev/null
+++ b/src/utils/retryingFetch.test.ts
@@ -0,0 +1,51 @@
+import retryingFetch from "./retryingFetch"
+import { http, HttpResponse } from "msw"
+import { setupServer } from "msw/node"
+
+const counter = jest.fn() // use jest.fn as counter because it resets on each test
+const NETWORK_SUCCESS_URL = "http://localhost:3456/success"
+const NETWORK_ERROR_URL = "http://localhost:3456/error"
+const server = setupServer(
+  http.get(NETWORK_SUCCESS_URL, async ({ request }) => {
+    counter()
+    const url = new URL(request.url)
+    const status = +(url.searchParams.get("status") ?? 200)
+    return HttpResponse.text(`Status ${status}`, { status })
+  }),
+  http.get(NETWORK_ERROR_URL, async () => {
+    counter()
+    return HttpResponse.error()
+  }),
+)
+beforeAll(() => server.listen())
+afterEach(() => server.resetHandlers())
+afterAll(() => server.close())
+
+describe("retryingFetch", () => {
+  beforeAll(() => {})
+
+  test.each([200, 201, 202, 367, 400, 401, 403])(
+    "should not retry on %s",
+    async (status) => {
+      const result = await retryingFetch(
+        `${NETWORK_SUCCESS_URL}?status=${status}`,
+      )
+      expect(await result.text()).toBe(`Status ${status}`)
+      expect(counter).toHaveBeenCalledTimes(1)
+    },
+  )
+
+  test.each([429, 500, 501, 502, 503])("should retry on %s", async (status) => {
+    const result = await retryingFetch(
+      `${NETWORK_SUCCESS_URL}?status=${status}`,
+    )
+    expect(await result.text()).toBe(`Status ${status}`)
+    expect(counter).toHaveBeenCalledTimes(4)
+  })
+
+  test("should retry on error", async () => {
+    const result = await retryingFetch(NETWORK_ERROR_URL).catch((err) => err)
+    expect(result).toBeInstanceOf(Error)
+    expect(counter).toHaveBeenCalledTimes(4)
+  })
+})
diff --git a/src/utils/retryingFetch.ts b/src/utils/retryingFetch.ts
new file mode 100644
index 00000000..7748956d
--- /dev/null
+++ b/src/utils/retryingFetch.ts
@@ -0,0 +1,44 @@
+import fetchRetrier from "fetch-retry"
+
+const MAX_RETRIES = 3
+const NO_RETRY_ERROR_CODES = [400, 401, 403, 404, 405, 409, 422]
+const BASE_RETRY_DELAY = process.env.NODE_ENV === "test" ? 10 : 1000
+
+// This is a workaround to ensure retryingFetch uses MSW's fetch version in tests.
+// See https://github.com/jonbern/fetch-retry/issues/95#issuecomment-2613990480
+const fetch: typeof window.fetch = (...args) => window.fetch(...args)
+
+/**
+ * A wrapper around fetch that retries the request on certain error codes.
+ *
+ * By default:
+ *
+ * Requests will be retried if max retries not exceeded and :
+ *  - The status code is >= 400 AND NOT in the NO_RETRY_ERROR_CODES list,
+ *  - OR the request promise rejected (network error)
+ *
+ * The retry delay is exponential, 1s, 2s, 4s, 8s, ... maxing at 30s.
+ *
+ * Maximum retries is 3.
+ *
+ * NOTE:
+ *  - When NODE_ENV="test", the maximum retries is set 0 by default but can be
+ *  set via the TEST_ENV_MAX_RETRIES environment variable.
+ */
+const retryingFetch = fetchRetrier(fetch, {
+  retryDelay: (attempt, _error, _response) => {
+    return Math.min(BASE_RETRY_DELAY * 2 ** attempt, 30_000)
+  },
+  retryOn: (attempt, _error, response) => {
+    if (attempt >= MAX_RETRIES) {
+      return false
+    }
+    if (response) {
+      if (response.status < 400) return false
+      return !NO_RETRY_ERROR_CODES.includes(response.status)
+    }
+    return true
+  },
+})
+
+export default retryingFetch
diff --git a/yarn.lock b/yarn.lock
index 4e659f0c..576ff16e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2834,6 +2834,7 @@ __metadata:
     eslint-plugin-react-hooks: "npm:^5.0.0"
     eslint-plugin-styled-components-a11y: "npm:^2.1.35"
     eslint-plugin-testing-library: "npm:^7.0.0"
+    fetch-retry: "npm:^6.0.0"
     jest: "npm:^29.7.0"
     jest-environment-jsdom: "npm:^29.5.0"
     jest-extended: "npm:^4.0.2"
@@ -9229,6 +9230,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"fetch-retry@npm:^6.0.0":
+  version: 6.0.0
+  resolution: "fetch-retry@npm:6.0.0"
+  checksum: 10/0c8d3082e2d76fff2df75adef6280bc854bc36fd3ef38506674f0216d0d819e2efd14da7477d3f1732415aea1d2cfde7cd3e1aeae46f45f2adbfc5133296e8de
+  languageName: node
+  linkType: hard
+
 "figures@npm:^2.0.0":
   version: 2.0.0
   resolution: "figures@npm:2.0.0"


Reviews:
user: login: jonkafton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T17:30:55Z
user: login: jonkafton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T17:34:41Z
user: login: jonkafton
body: Looks great, works great!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T17:34:47Z
user: login: ChristopherChudzicki
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T18:22:21Z

Review comments:
diff_hunk: @@ -100,6 +100,11 @@ type AiChatDisplayProps = {
    * Defaults to false.
    */
   useMathJax?: boolean
+  /**
+   * If true, the chat input will be autofocused on load.
+   * Defaults to true.
+   */
+  autofocus?: boolean
path: src/components/AiChat/types.ts
user: login: jonkafton
body: This slipped in from our other issue.
created_at: 2025-05-08T17:30:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,44 @@
+import fetchRetrier from "fetch-retry"
+
+const MAX_RETRIES = 3
+const NO_RETRY_ERROR_CODES = [400, 401, 403, 404, 405, 409, 422]
+const BASE_RETRY_DELAY = process.env.NODE_ENV === "test" ? 10 : 1000
+
+// This is a workaround to ensure retryingFetch uses MSW's fetch version in tests.
+// See https://github.com/jonbern/fetch-retry/issues/95#issuecomment-2613990480
+const fetch: typeof window.fetch = (...args) => window.fetch(...args)
+
+/**
+ * A wrapper around fetch that retries the request on certain error codes.
+ *
+ * By default:
+ *
+ * Requests will be retried if max retries not exceeded and :
+ *  - The status code is >= 400 AND NOT in the NO_RETRY_ERROR_CODES list,
+ *  - OR the request promise rejected (network error)
+ *
+ * The retry delay is exponential, 1s, 2s, 4s, 8s, ... maxing at 30s.
+ *
+ * Maximum retries is 3.
+ *
+ * NOTE:
+ *  - When NODE_ENV="test", the maximum retries is set 0 by default but can be
+ *  set via the TEST_ENV_MAX_RETRIES environment variable.
+ */
+const retryingFetch = fetchRetrier(fetch, {
+  retryDelay: (attempt, _error, _response) => {
+    return Math.min(BASE_RETRY_DELAY * 2 ** attempt, 30_000)
+  },
+  retryOn: (attempt, _error, response) => {
+    if (attempt >= MAX_RETRIES) {
+      return false
+    }
+    if (response) {
+      if (response.status < 400) return false
path: src/utils/retryingFetch.ts
user: login: jonkafton
body: Is this just for good measure? I wouldn't expect any status < 400 to produce an error to retry on.
created_at: 2025-05-08T17:34:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,44 @@
+import fetchRetrier from "fetch-retry"
+
+const MAX_RETRIES = 3
+const NO_RETRY_ERROR_CODES = [400, 401, 403, 404, 405, 409, 422]
+const BASE_RETRY_DELAY = process.env.NODE_ENV === "test" ? 10 : 1000
+
+// This is a workaround to ensure retryingFetch uses MSW's fetch version in tests.
+// See https://github.com/jonbern/fetch-retry/issues/95#issuecomment-2613990480
+const fetch: typeof window.fetch = (...args) => window.fetch(...args)
+
+/**
+ * A wrapper around fetch that retries the request on certain error codes.
+ *
+ * By default:
+ *
+ * Requests will be retried if max retries not exceeded and :
+ *  - The status code is >= 400 AND NOT in the NO_RETRY_ERROR_CODES list,
+ *  - OR the request promise rejected (network error)
+ *
+ * The retry delay is exponential, 1s, 2s, 4s, 8s, ... maxing at 30s.
+ *
+ * Maximum retries is 3.
+ *
+ * NOTE:
+ *  - When NODE_ENV="test", the maximum retries is set 0 by default but can be
+ *  set via the TEST_ENV_MAX_RETRIES environment variable.
+ */
+const retryingFetch = fetchRetrier(fetch, {
+  retryDelay: (attempt, _error, _response) => {
+    return Math.min(BASE_RETRY_DELAY * 2 ** attempt, 30_000)
+  },
+  retryOn: (attempt, _error, response) => {
+    if (attempt >= MAX_RETRIES) {
+      return false
+    }
+    if (response) {
+      if (response.status < 400) return false
path: src/utils/retryingFetch.ts
user: login: ChristopherChudzicki
body: This is actually necessary: If you implement `retryOn` as a function, then that is what determines retry, **period**.

And for fetch:
- promise resolve = network response (possibly an error response, based on status code)
- promise reject = network error 

So we really do need to check the status code and explicitly say "Do not retry if < 400".

--------

Alternatively, we could specify `retryOn: <array of whitelisted status codes to retry>`.

But if we want to retry everything except a specific list, then we need to do it this way.
created_at: 2025-05-08T18:22:21Z
author_association: COLLABORATOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-09T16:13:03Z
body: :tada: This PR is included in version 6.5.0 :tada:

The release is available on:
- [GitHub release](https://github.com/mitodl/smoot-design/releases/tag/v6.5.0)
- [npm package (@latest dist-tag)](https://www.npmjs.com/package/@mitodl/smoot-design/v/6.5.0)

Your **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:
author_association: NONE