From ed9a4649ad08730e944dbf48249d2d07db3ce4a3 Mon Sep 17 00:00:00 2001 From: hunterckx <118154470+hunterckx@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:24:41 -0700 Subject: [PATCH 01/11] feat: define azul ky options (#4561) --- __tests__/site-config/azul-ky-options.test.ts | 50 +++++++++++++ package-lock.json | 70 +++++++++++++++++++ package.json | 1 + site-config/common/constants.ts | 17 +++++ 4 files changed, 138 insertions(+) create mode 100644 __tests__/site-config/azul-ky-options.test.ts diff --git a/__tests__/site-config/azul-ky-options.test.ts b/__tests__/site-config/azul-ky-options.test.ts new file mode 100644 index 000000000..a09a38871 --- /dev/null +++ b/__tests__/site-config/azul-ky-options.test.ts @@ -0,0 +1,50 @@ +import { setConfig } from "@databiosphere/findable-ui/lib/config/config.js"; +import { SiteConfig } from "@databiosphere/findable-ui/lib/config/entities"; +import { fetchApi } from "@databiosphere/findable-ui/lib/entity/common/client"; +import { jest } from "@jest/globals"; +import fetchMock from "jest-fetch-mock"; +import { AZUL_KY_OPTIONS } from "site-config/common/constants"; + +// The test site config includes only the relevant parts of the site config in the return value +const TEST_SITE_CONFIG: Pick = { + dataSource: { + url: "http://example.com", + }, + kyOptions: AZUL_KY_OPTIONS, +}; + +jest + .spyOn(globalThis, "setTimeout") + .mockImplementation(((callback: () => void) => + callback()) as typeof setTimeout); + +beforeAll(async () => { + setConfig(TEST_SITE_CONFIG as SiteConfig); + fetchMock.doMock(); + globalThis.fetch = ((...args) => { + return fetchMock(...(args as Parameters)); + }) as typeof fetch; + AbortSignal.prototype.throwIfAborted = function (): void { + if (this.aborted) throw this.reason; + }; +}); + +describe("api", () => { + it("retries based on ky options in site config", async () => { + fetchMock.mockResponseOnce("", { status: 429 }); + await expect(fetchApi("")).rejects.toMatchObject({ + response: { + status: 429, + }, + }); + + fetchMock.mockResponseOnce("", { + headers: { "Retry-After": "5" }, + status: 429, + }); + fetchMock.mockResponseOnce("after-retry"); + const resTrue = await fetchApi(""); + expect(resTrue.status).toBe(200); + expect(await resTrue.text()).toBe("after-retry"); + }); +}); diff --git a/package-lock.json b/package-lock.json index daf1a7782..9511ebc6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,6 +80,7 @@ "husky": "^9.1.7", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "jest-fetch-mock": "^4.2.0", "prettier": "^3.7.4", "prettier-plugin-organize-imports": "^4.3.0", "typescript": "^5.9.3", @@ -7553,6 +7554,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -12661,6 +12672,19 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-fetch-mock": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-4.2.0.tgz", + "integrity": "sha512-/Yya+k/bopdCSG+cEIL9Kxj1cr4j73DeOQiroicwrTkTEfyMsnQxrugjSopn8RnwginmX/Nlsp57BGy1I1mQSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-fetch": "^3.1.8" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/jest-get-type": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", @@ -15862,6 +15886,52 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", diff --git a/package.json b/package.json index d5a1b0193..0e796bdd3 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "husky": "^9.1.7", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "jest-fetch-mock": "^4.2.0", "prettier": "^3.7.4", "prettier-plugin-organize-imports": "^4.3.0", "typescript": "^5.9.3", diff --git a/site-config/common/constants.ts b/site-config/common/constants.ts index f8223c006..7862c3a1f 100644 --- a/site-config/common/constants.ts +++ b/site-config/common/constants.ts @@ -1,5 +1,22 @@ import { BreakpointKey } from "@databiosphere/findable-ui/lib/hooks/useBreakpointHelper"; import { Breakpoints } from "@mui/system"; +import { HTTPError, Options as KyOptions } from "ky"; + +export const AZUL_KY_OPTIONS = { + retry: { + shouldRetry({ error }): boolean | undefined { + // If the Retry-After header exists and has content, use default behavior + if ( + error instanceof HTTPError && + error.response.headers.get("Retry-After") + ) { + return undefined; + } + // Otherwise, never retry requests that received responses, and use default behavior for those that didn't (e.g. due to network errors) + return "response" in error ? false : undefined; + }, + }, +} satisfies KyOptions; export const BREAKPOINTS: Partial = { values: { From b5b314c8060ddffdc86281969ef3c313f8d117ec Mon Sep 17 00:00:00 2001 From: hunterckx <118154470+hunterckx@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:06:48 -0700 Subject: [PATCH 02/11] test: fix azul ky options test (#4561) --- __tests__/site-config/azul-ky-options.test.ts | 21 ++++++++++++------- jest.config.js | 17 +++++++++++++-- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/__tests__/site-config/azul-ky-options.test.ts b/__tests__/site-config/azul-ky-options.test.ts index a09a38871..6c3f284c7 100644 --- a/__tests__/site-config/azul-ky-options.test.ts +++ b/__tests__/site-config/azul-ky-options.test.ts @@ -1,10 +1,15 @@ +/** + * @jest-environment node + */ import { setConfig } from "@databiosphere/findable-ui/lib/config/config.js"; import { SiteConfig } from "@databiosphere/findable-ui/lib/config/entities"; import { fetchApi } from "@databiosphere/findable-ui/lib/entity/common/client"; -import { jest } from "@jest/globals"; import fetchMock from "jest-fetch-mock"; import { AZUL_KY_OPTIONS } from "site-config/common/constants"; +// Use the actual Ky rather than the mock in __mocks__ +jest.unmock("ky"); + // The test site config includes only the relevant parts of the site config in the return value const TEST_SITE_CONFIG: Pick = { dataSource: { @@ -13,10 +18,13 @@ const TEST_SITE_CONFIG: Pick = { kyOptions: AZUL_KY_OPTIONS, }; -jest - .spyOn(globalThis, "setTimeout") - .mockImplementation(((callback: () => void) => - callback()) as typeof setTimeout); +jest.spyOn(globalThis, "setTimeout").mockImplementation((( + callback: () => void, + timeout: number +) => { + // Ky has a built in 20-second timeout that causes the request to fail, which we don't want to call the callback for + if (timeout !== 20000) callback(); +}) as typeof setTimeout); beforeAll(async () => { setConfig(TEST_SITE_CONFIG as SiteConfig); @@ -24,9 +32,6 @@ beforeAll(async () => { globalThis.fetch = ((...args) => { return fetchMock(...(args as Parameters)); }) as typeof fetch; - AbortSignal.prototype.throwIfAborted = function (): void { - if (this.aborted) throw this.reason; - }; }); describe("api", () => { diff --git a/jest.config.js b/jest.config.js index 057d021e5..357a49282 100644 --- a/jest.config.js +++ b/jest.config.js @@ -20,7 +20,6 @@ const customJestConfig = { // chain, which next/jest leaves untransformed; stub it (no test renders markdown). "^@databiosphere/findable-ui/lib/components/MarkdownRenderer/markdownRenderer$": "/__mocks__/markdownRenderer.tsx", - "^ky$": "/__mocks__/ky.ts", }, testEnvironment: "jest-environment-jsdom", testPathIgnorePatterns: [ @@ -32,5 +31,19 @@ const customJestConfig = { ], }; +// ESM-only packages in node_modules that need to be transformed for Jest, in addition to the +// packages in next.config's transpilePackages (which next/jest already exempts from transformIgnorePatterns). +const ESM_PACKAGES = ["ky"]; + // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async -module.exports = createJestConfig(customJestConfig); +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -- Plain JS file; can't annotate return type +module.exports = async () => { + const config = await createJestConfig(customJestConfig)(); + return { + ...config, + // Add the ESM packages to next/jest's node_modules exemption lists, which are of the form "(?!(pkg-a|pkg-b|...)". + transformIgnorePatterns: config.transformIgnorePatterns.map((pattern) => + pattern.replace("(?!(", `(?!(${ESM_PACKAGES.join("|")}|`) + ), + }; +}; From fd18589f9e11e43784b0aa48ab2b5762b9e6bd27 Mon Sep 17 00:00:00 2001 From: hunterckx <118154470+hunterckx@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:10:13 -0700 Subject: [PATCH 03/11] refactor: transpile ky via next config rather than fragile replacement in ignore paths (#4561) --- jest.config.js | 16 +--------------- next.config.mjs | 6 +++++- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/jest.config.js b/jest.config.js index 357a49282..dd45681c5 100644 --- a/jest.config.js +++ b/jest.config.js @@ -31,19 +31,5 @@ const customJestConfig = { ], }; -// ESM-only packages in node_modules that need to be transformed for Jest, in addition to the -// packages in next.config's transpilePackages (which next/jest already exempts from transformIgnorePatterns). -const ESM_PACKAGES = ["ky"]; - // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -- Plain JS file; can't annotate return type -module.exports = async () => { - const config = await createJestConfig(customJestConfig)(); - return { - ...config, - // Add the ESM packages to next/jest's node_modules exemption lists, which are of the form "(?!(pkg-a|pkg-b|...)". - transformIgnorePatterns: config.transformIgnorePatterns.map((pattern) => - pattern.replace("(?!(", `(?!(${ESM_PACKAGES.join("|")}|`) - ), - }; -}; +module.exports = createJestConfig(customJestConfig); diff --git a/next.config.mjs b/next.config.mjs index 1220cd2b1..391be434f 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -14,5 +14,9 @@ export default withMDX({ pageExtensions: ["md", "mdx", "ts", "tsx"], reactStrictMode: true, staticPageGenerationTimeout: 120, - transpilePackages: ["@databiosphere/findable-ui"], + transpilePackages: [ + "@databiosphere/findable-ui", + // Ky is transpiled for compatibility with Jest tests + "ky", + ], }); From ab0234e31aa2cd2d8b5de31a53df001f3c8a51f2 Mon Sep 17 00:00:00 2001 From: hunterckx <118154470+hunterckx@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:24:04 -0700 Subject: [PATCH 04/11] feat: use azul ky options for azul-consuming sites (#4561) --- site-config/anvil-cmg/dev/config.ts | 8 +++++++- site-config/hca-dcp/ma-dev/config.ts | 3 ++- site-config/lungmap/dev/config.ts | 7 ++++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/site-config/anvil-cmg/dev/config.ts b/site-config/anvil-cmg/dev/config.ts index ba8853c5b..1d816162d 100644 --- a/site-config/anvil-cmg/dev/config.ts +++ b/site-config/anvil-cmg/dev/config.ts @@ -8,7 +8,12 @@ import { mapSelectCategoryValue } from "../../../app/config/utils"; import { buildDataDictionary } from "../../../app/viewModelBuilders/azul/anvil-cmg/common/dataDictionaryMapper/dataDictionaryMapper"; import { TABLE_OPTIONS } from "../../../app/viewModelBuilders/azul/anvil-cmg/common/dataDictionaryMapper/tableOptions"; import { bindSystemStatusResponse } from "../../../app/viewModelBuilders/azul/common/systemStatusMapper/systemStatusMapper"; -import { FLATTEN, GIT_HUB_REPO_URL, VISIBLE } from "../../common/constants"; +import { + AZUL_KY_OPTIONS, + FLATTEN, + GIT_HUB_REPO_URL, + VISIBLE, +} from "../../common/constants"; import { SiteConfig } from "../../common/entities"; import { ANVIL_CMG_CATEGORY_KEY, ANVIL_CMG_CATEGORY_LABEL } from "../category"; import { announcements } from "./announcements/announcements"; @@ -175,6 +180,7 @@ export function makeConfig( exportToTerraUrl: "https://bvdp-saturn-dev.appspot.com/", filterSort: { sortBy: FILTER_SORT.COUNT }, gitHubUrl, + kyOptions: AZUL_KY_OPTIONS, layout: { floating: makeFloatingConfig(portalUrl), footer: { diff --git a/site-config/hca-dcp/ma-dev/config.ts b/site-config/hca-dcp/ma-dev/config.ts index cd381bcc9..5a0dcafc3 100644 --- a/site-config/hca-dcp/ma-dev/config.ts +++ b/site-config/hca-dcp/ma-dev/config.ts @@ -2,7 +2,7 @@ import { APIEndpoints } from "@databiosphere/findable-ui/lib/apis/azul/common/en import { FILTER_SORT } from "@databiosphere/findable-ui/lib/common/filters/sort/config/types"; import { SystemStatusBindResponseFn } from "@databiosphere/findable-ui/lib/config/entities"; import { bindSystemStatusResponse } from "../../../app/viewModelBuilders/azul/common/systemStatusMapper/systemStatusMapper"; -import { GIT_HUB_REPO_URL } from "../../common/constants"; +import { AZUL_KY_OPTIONS, GIT_HUB_REPO_URL } from "../../common/constants"; import { SiteConfig } from "../../common/entities"; import { getAuthentication } from "./authentication/authentication"; import { CATEGORY_GROUP_CONFIG } from "./categoryGroup/categoryGroupConfig"; @@ -59,6 +59,7 @@ export function makeConfig( exportToTerraUrl: EXPORT_TO_TERRA_URL, filterSort: { sortBy: FILTER_SORT.COUNT }, gitHubUrl, + kyOptions: AZUL_KY_OPTIONS, layout: getLayout(APP_TITLE, portalUrl), portalURL: portalUrl, redirectRootToPath: REDIRECT_ROOT_TO_PATH, diff --git a/site-config/lungmap/dev/config.ts b/site-config/lungmap/dev/config.ts index cfb0928c2..eed20c01a 100644 --- a/site-config/lungmap/dev/config.ts +++ b/site-config/lungmap/dev/config.ts @@ -1,6 +1,10 @@ import { FILTER_SORT } from "@databiosphere/findable-ui/lib/common/filters/sort/config/types"; import * as C from "../../../app/components/index"; -import { FLATTEN, GIT_HUB_REPO_URL } from "../../common/constants"; +import { + AZUL_KY_OPTIONS, + FLATTEN, + GIT_HUB_REPO_URL, +} from "../../common/constants"; import { SiteConfig } from "../../common/entities"; import { DATA_URL as HCA_DATA_URL, @@ -60,6 +64,7 @@ export function makeConfig( exportToTerraUrl: EXPORT_TO_TERRA_URL, filterSort: { sortBy: FILTER_SORT.COUNT }, gitHubUrl, + kyOptions: AZUL_KY_OPTIONS, layout: { footer: { Branding: C.Logo({ From 16e27b1a2284829e8fae3eaf4c73e2977477aa52 Mon Sep 17 00:00:00 2001 From: hunterckx <118154470+hunterckx@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:38:24 -0700 Subject: [PATCH 05/11] test: additional azul ky options tests (#4561) --- __tests__/site-config/azul-ky-options.test.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/__tests__/site-config/azul-ky-options.test.ts b/__tests__/site-config/azul-ky-options.test.ts index 6c3f284c7..c0f77c158 100644 --- a/__tests__/site-config/azul-ky-options.test.ts +++ b/__tests__/site-config/azul-ky-options.test.ts @@ -35,14 +35,28 @@ beforeAll(async () => { }); describe("api", () => { - it("retries based on ky options in site config", async () => { + it("does not retry when Retry-After is absent", async () => { fetchMock.mockResponseOnce("", { status: 429 }); await expect(fetchApi("")).rejects.toMatchObject({ response: { status: 429, }, }); + }); + it("does not retry when Retry-After contains empty string", async () => { + fetchMock.mockResponseOnce("", { + headers: { "Retry-After": "" }, + status: 429, + }); + await expect(fetchApi("")).rejects.toMatchObject({ + response: { + status: 429, + }, + }); + }); + + it("retries when Retry-After has content", async () => { fetchMock.mockResponseOnce("", { headers: { "Retry-After": "5" }, status: 429, @@ -52,4 +66,12 @@ describe("api", () => { expect(resTrue.status).toBe(200); expect(await resTrue.text()).toBe("after-retry"); }); + + it("retries on fetch failure", async () => { + fetchMock.mockRejectOnce(new TypeError("Failed to fetch")); + fetchMock.mockResponseOnce("after-retry"); + const resTrue = await fetchApi(""); + expect(resTrue.status).toBe(200); + expect(await resTrue.text()).toBe("after-retry"); + }); }); From c217b6976929fc9aa334b5159b0790420e450309 Mon Sep 17 00:00:00 2001 From: hunterckx <118154470+hunterckx@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:55:14 -0700 Subject: [PATCH 06/11] chore: update findable-ui (#4561) --- package-lock.json | 12 ++++++++---- package.json | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9511ebc6d..9349db4f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "explorer", "version": "3.7.0", "dependencies": { - "@databiosphere/findable-ui": "^57.0.0", + "@databiosphere/findable-ui": "^57.2.0", "@emotion/react": "^11", "@emotion/server": "^11", "@emotion/styled": "^11", @@ -908,9 +908,9 @@ "license": "MIT" }, "node_modules/@databiosphere/findable-ui": { - "version": "57.0.0", - "resolved": "https://registry.npmjs.org/@databiosphere/findable-ui/-/findable-ui-57.0.0.tgz", - "integrity": "sha512-8ncfWQpdJJdypQJSp1FFsY/ENc1scrqXdUj0muY4CISM3YmxJM9ETAH6Tu+lcv+LrK9c1T0rhkTL4OaOdKq5Iw==", + "version": "57.2.0", + "resolved": "https://registry.npmjs.org/@databiosphere/findable-ui/-/findable-ui-57.2.0.tgz", + "integrity": "sha512-HdtqF88mXx02sl8i5EVCbKeqKnz7yHkR+6Dpe21XME8YwIJCBIObx5qZejC5RLouT+ghMXMBmv7jx+rLSDjN0Q==", "license": "Apache-2.0", "engines": { "node": "22.13.0" @@ -944,6 +944,7 @@ "slugify": "^1.6.6", "unified": "^11.0.5", "uuid": "^13.0.0", + "yet-another-react-lightbox": "^3.32.2", "yup": "^1.7.1" }, "peerDependenciesMeta": { @@ -952,6 +953,9 @@ }, "next-auth": { "optional": true + }, + "yet-another-react-lightbox": { + "optional": true } } }, diff --git a/package.json b/package.json index 0e796bdd3..f0800cf0d 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "refresh-diagnosis-terms:anvil-cmg": "esrun scripts/lookup-diagnosis-terms.ts" }, "dependencies": { - "@databiosphere/findable-ui": "^57.0.0", + "@databiosphere/findable-ui": "^57.2.0", "@emotion/react": "^11", "@emotion/server": "^11", "@emotion/styled": "^11", From af96c02ee5cdcb52086c507c07dea292ed59e389 Mon Sep 17 00:00:00 2001 From: hunterckx <118154470+hunterckx@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:22:54 -0700 Subject: [PATCH 07/11] test: test additional status codes (#4561) --- __tests__/site-config/azul-ky-options.test.ts | 72 ++++++++++--------- 1 file changed, 38 insertions(+), 34 deletions(-) diff --git a/__tests__/site-config/azul-ky-options.test.ts b/__tests__/site-config/azul-ky-options.test.ts index c0f77c158..fb7ea60a2 100644 --- a/__tests__/site-config/azul-ky-options.test.ts +++ b/__tests__/site-config/azul-ky-options.test.ts @@ -34,44 +34,48 @@ beforeAll(async () => { }) as typeof fetch; }); +const STATUS_CODES_TO_TEST = [429, 500, 502, 503, 504]; + describe("api", () => { - it("does not retry when Retry-After is absent", async () => { - fetchMock.mockResponseOnce("", { status: 429 }); - await expect(fetchApi("")).rejects.toMatchObject({ - response: { - status: 429, - }, - }); - }); + for (const status of STATUS_CODES_TO_TEST) { + describe(`reponse status ${status}`, () => { + it(`does not retry when Retry-After is absent`, async () => { + fetchMock.mockResponseOnce("", { status }); + await expect(fetchApi("")).rejects.toMatchObject({ + response: { status }, + }); + }); - it("does not retry when Retry-After contains empty string", async () => { - fetchMock.mockResponseOnce("", { - headers: { "Retry-After": "" }, - status: 429, - }); - await expect(fetchApi("")).rejects.toMatchObject({ - response: { - status: 429, - }, - }); - }); + it("does not retry when Retry-After contains empty string", async () => { + fetchMock.mockResponseOnce("", { + headers: { "Retry-After": "" }, + status, + }); + await expect(fetchApi("")).rejects.toMatchObject({ + response: { status }, + }); + }); - it("retries when Retry-After has content", async () => { - fetchMock.mockResponseOnce("", { - headers: { "Retry-After": "5" }, - status: 429, + it("retries when Retry-After has content", async () => { + fetchMock.mockResponseOnce("", { + headers: { "Retry-After": "5" }, + status, + }); + fetchMock.mockResponseOnce("after-retry"); + const resTrue = await fetchApi(""); + expect(resTrue.status).toBe(200); + expect(await resTrue.text()).toBe("after-retry"); + }); }); - fetchMock.mockResponseOnce("after-retry"); - const resTrue = await fetchApi(""); - expect(resTrue.status).toBe(200); - expect(await resTrue.text()).toBe("after-retry"); - }); + } - it("retries on fetch failure", async () => { - fetchMock.mockRejectOnce(new TypeError("Failed to fetch")); - fetchMock.mockResponseOnce("after-retry"); - const resTrue = await fetchApi(""); - expect(resTrue.status).toBe(200); - expect(await resTrue.text()).toBe("after-retry"); + describe("other errors", () => { + it("retries on fetch failure", async () => { + fetchMock.mockRejectOnce(new TypeError("Failed to fetch")); + fetchMock.mockResponseOnce("after-retry"); + const resTrue = await fetchApi(""); + expect(resTrue.status).toBe(200); + expect(await resTrue.text()).toBe("after-retry"); + }); }); }); From 9517d140fee4eb16051501cc975a5f1303930d36 Mon Sep 17 00:00:00 2001 From: hunterckx <118154470+hunterckx@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:44:02 -0700 Subject: [PATCH 08/11] fix: use retry-after for all retryable status codes (#4561) --- __tests__/site-config/azul-ky-options.test.ts | 19 ++++++++++--------- site-config/common/constants.ts | 5 +++++ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/__tests__/site-config/azul-ky-options.test.ts b/__tests__/site-config/azul-ky-options.test.ts index fb7ea60a2..2bb97f0f5 100644 --- a/__tests__/site-config/azul-ky-options.test.ts +++ b/__tests__/site-config/azul-ky-options.test.ts @@ -18,13 +18,12 @@ const TEST_SITE_CONFIG: Pick = { kyOptions: AZUL_KY_OPTIONS, }; -jest.spyOn(globalThis, "setTimeout").mockImplementation((( - callback: () => void, - timeout: number -) => { - // Ky has a built in 20-second timeout that causes the request to fail, which we don't want to call the callback for - if (timeout !== 20000) callback(); -}) as typeof setTimeout); +const mockSetTimeout = jest + .spyOn(globalThis, "setTimeout") + .mockImplementation(((callback: () => void, timeout: number) => { + // Ky has a built in 20-second timeout that causes the request to fail, which we don't want to call the callback for + if (timeout !== 20000) callback(); + }) as typeof setTimeout); beforeAll(async () => { setConfig(TEST_SITE_CONFIG as SiteConfig); @@ -56,15 +55,17 @@ describe("api", () => { }); }); - it("retries when Retry-After has content", async () => { + it("retries based on Retry-After when it has content", async () => { + mockSetTimeout.mockClear(); fetchMock.mockResponseOnce("", { - headers: { "Retry-After": "5" }, + headers: { "Retry-After": "345" }, status, }); fetchMock.mockResponseOnce("after-retry"); const resTrue = await fetchApi(""); expect(resTrue.status).toBe(200); expect(await resTrue.text()).toBe("after-retry"); + expect(mockSetTimeout.mock.calls[1][1]).toBe(345000); }); }); } diff --git a/site-config/common/constants.ts b/site-config/common/constants.ts index 7862c3a1f..d0ed54de0 100644 --- a/site-config/common/constants.ts +++ b/site-config/common/constants.ts @@ -2,8 +2,12 @@ import { BreakpointKey } from "@databiosphere/findable-ui/lib/hooks/useBreakpoin import { Breakpoints } from "@mui/system"; import { HTTPError, Options as KyOptions } from "ky"; +const KY_RETRY_STATUSES = [408, 413, 429, 500, 502, 503, 504]; + export const AZUL_KY_OPTIONS = { retry: { + // Any status code that can be retried should have its retry delay based on Retry-After + afterStatusCodes: KY_RETRY_STATUSES, shouldRetry({ error }): boolean | undefined { // If the Retry-After header exists and has content, use default behavior if ( @@ -15,6 +19,7 @@ export const AZUL_KY_OPTIONS = { // Otherwise, never retry requests that received responses, and use default behavior for those that didn't (e.g. due to network errors) return "response" in error ? false : undefined; }, + statusCodes: KY_RETRY_STATUSES, }, } satisfies KyOptions; From dddc3af104f4c74369a766896bb990a93b3b54b3 Mon Sep 17 00:00:00 2001 From: hunterckx <118154470+hunterckx@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:48:21 -0700 Subject: [PATCH 09/11] test: test that 400 is never retried (#4561) --- __tests__/site-config/azul-ky-options.test.ts | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/__tests__/site-config/azul-ky-options.test.ts b/__tests__/site-config/azul-ky-options.test.ts index 2bb97f0f5..174929500 100644 --- a/__tests__/site-config/azul-ky-options.test.ts +++ b/__tests__/site-config/azul-ky-options.test.ts @@ -33,7 +33,8 @@ beforeAll(async () => { }) as typeof fetch; }); -const STATUS_CODES_TO_TEST = [429, 500, 502, 503, 504]; +const STATUS_CODES_TO_TEST = [400, 429, 500, 502, 503, 504]; +const NO_RETRY_STATUS_CODES = [400]; describe("api", () => { for (const status of STATUS_CODES_TO_TEST) { @@ -55,18 +56,31 @@ describe("api", () => { }); }); - it("retries based on Retry-After when it has content", async () => { - mockSetTimeout.mockClear(); - fetchMock.mockResponseOnce("", { - headers: { "Retry-After": "345" }, - status, + if (NO_RETRY_STATUS_CODES.includes(status)) { + it("does not retry when Retry-After has content", async () => { + mockSetTimeout.mockClear(); + fetchMock.mockResponseOnce("", { + headers: { "Retry-After": "345" }, + status, + }); + await expect(fetchApi("")).rejects.toMatchObject({ + response: { status }, + }); }); - fetchMock.mockResponseOnce("after-retry"); - const resTrue = await fetchApi(""); - expect(resTrue.status).toBe(200); - expect(await resTrue.text()).toBe("after-retry"); - expect(mockSetTimeout.mock.calls[1][1]).toBe(345000); - }); + } else { + it("retries based on Retry-After when it has content", async () => { + mockSetTimeout.mockClear(); + fetchMock.mockResponseOnce("", { + headers: { "Retry-After": "345" }, + status, + }); + fetchMock.mockResponseOnce("after-retry"); + const resTrue = await fetchApi(""); + expect(resTrue.status).toBe(200); + expect(await resTrue.text()).toBe("after-retry"); + expect(mockSetTimeout.mock.calls[1][1]).toBe(345000); + }); + } }); } From 990e2324d8d291517e99808c2f9e2ab28bf4ef05 Mon Sep 17 00:00:00 2001 From: Hunter Craft <118154470+hunterckx@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:33:52 -0700 Subject: [PATCH 10/11] fix: typo in test description for response status (#4561) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- __tests__/site-config/azul-ky-options.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/site-config/azul-ky-options.test.ts b/__tests__/site-config/azul-ky-options.test.ts index 174929500..1a452b1a1 100644 --- a/__tests__/site-config/azul-ky-options.test.ts +++ b/__tests__/site-config/azul-ky-options.test.ts @@ -38,7 +38,7 @@ const NO_RETRY_STATUS_CODES = [400]; describe("api", () => { for (const status of STATUS_CODES_TO_TEST) { - describe(`reponse status ${status}`, () => { + describe(`response status ${status}`, () => { it(`does not retry when Retry-After is absent`, async () => { fetchMock.mockResponseOnce("", { status }); await expect(fetchApi("")).rejects.toMatchObject({ From 94cf37a5f57e5bcfec41fd93044560430ce71ebe Mon Sep 17 00:00:00 2001 From: hunterckx <118154470+hunterckx@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:40:54 -0700 Subject: [PATCH 11/11] refactor: drop `jest-fetch-mock` (#4561) --- __tests__/site-config/azul-ky-options.test.ts | 30 ++++---- package-lock.json | 70 ------------------- package.json | 1 - 3 files changed, 18 insertions(+), 83 deletions(-) diff --git a/__tests__/site-config/azul-ky-options.test.ts b/__tests__/site-config/azul-ky-options.test.ts index 1a452b1a1..693857e28 100644 --- a/__tests__/site-config/azul-ky-options.test.ts +++ b/__tests__/site-config/azul-ky-options.test.ts @@ -4,7 +4,6 @@ import { setConfig } from "@databiosphere/findable-ui/lib/config/config.js"; import { SiteConfig } from "@databiosphere/findable-ui/lib/config/entities"; import { fetchApi } from "@databiosphere/findable-ui/lib/entity/common/client"; -import fetchMock from "jest-fetch-mock"; import { AZUL_KY_OPTIONS } from "site-config/common/constants"; // Use the actual Ky rather than the mock in __mocks__ @@ -25,12 +24,11 @@ const mockSetTimeout = jest if (timeout !== 20000) callback(); }) as typeof setTimeout); +const mockFetch = jest.fn(); + beforeAll(async () => { setConfig(TEST_SITE_CONFIG as SiteConfig); - fetchMock.doMock(); - globalThis.fetch = ((...args) => { - return fetchMock(...(args as Parameters)); - }) as typeof fetch; + globalThis.fetch = mockFetch; }); const STATUS_CODES_TO_TEST = [400, 429, 500, 502, 503, 504]; @@ -40,14 +38,14 @@ describe("api", () => { for (const status of STATUS_CODES_TO_TEST) { describe(`response status ${status}`, () => { it(`does not retry when Retry-After is absent`, async () => { - fetchMock.mockResponseOnce("", { status }); + mockResponseOnce("", { status }); await expect(fetchApi("")).rejects.toMatchObject({ response: { status }, }); }); it("does not retry when Retry-After contains empty string", async () => { - fetchMock.mockResponseOnce("", { + mockResponseOnce("", { headers: { "Retry-After": "" }, status, }); @@ -59,7 +57,7 @@ describe("api", () => { if (NO_RETRY_STATUS_CODES.includes(status)) { it("does not retry when Retry-After has content", async () => { mockSetTimeout.mockClear(); - fetchMock.mockResponseOnce("", { + mockResponseOnce("", { headers: { "Retry-After": "345" }, status, }); @@ -70,11 +68,11 @@ describe("api", () => { } else { it("retries based on Retry-After when it has content", async () => { mockSetTimeout.mockClear(); - fetchMock.mockResponseOnce("", { + mockResponseOnce("", { headers: { "Retry-After": "345" }, status, }); - fetchMock.mockResponseOnce("after-retry"); + mockResponseOnce("after-retry"); const resTrue = await fetchApi(""); expect(resTrue.status).toBe(200); expect(await resTrue.text()).toBe("after-retry"); @@ -86,11 +84,19 @@ describe("api", () => { describe("other errors", () => { it("retries on fetch failure", async () => { - fetchMock.mockRejectOnce(new TypeError("Failed to fetch")); - fetchMock.mockResponseOnce("after-retry"); + mockRejectOnce(new TypeError("Failed to fetch")); + mockResponseOnce("after-retry"); const resTrue = await fetchApi(""); expect(resTrue.status).toBe(200); expect(await resTrue.text()).toBe("after-retry"); }); }); }); + +function mockResponseOnce(data?: BodyInit | null, init?: ResponseInit): void { + mockFetch.mockResolvedValueOnce(new Response(data, init)); +} + +function mockRejectOnce(error: unknown): void { + mockFetch.mockRejectedValueOnce(error); +} diff --git a/package-lock.json b/package-lock.json index 9349db4f0..5afdfb0c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,7 +80,6 @@ "husky": "^9.1.7", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", - "jest-fetch-mock": "^4.2.0", "prettier": "^3.7.4", "prettier-plugin-organize-imports": "^4.3.0", "typescript": "^5.9.3", @@ -7558,16 +7557,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/cross-fetch": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", - "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "node-fetch": "^2.7.0" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -12676,19 +12665,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-fetch-mock": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-4.2.0.tgz", - "integrity": "sha512-/Yya+k/bopdCSG+cEIL9Kxj1cr4j73DeOQiroicwrTkTEfyMsnQxrugjSopn8RnwginmX/Nlsp57BGy1I1mQSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-fetch": "^3.1.8" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/jest-get-type": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", @@ -15890,52 +15866,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-fetch/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-fetch/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/node-fetch/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", diff --git a/package.json b/package.json index f0800cf0d..9edece851 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,6 @@ "husky": "^9.1.7", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", - "jest-fetch-mock": "^4.2.0", "prettier": "^3.7.4", "prettier-plugin-organize-imports": "^4.3.0", "typescript": "^5.9.3",