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..693857e28 --- /dev/null +++ b/__tests__/site-config/azul-ky-options.test.ts @@ -0,0 +1,102 @@ +/** + * @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 { 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: { + url: "http://example.com", + }, + kyOptions: AZUL_KY_OPTIONS, +}; + +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); + +const mockFetch = jest.fn(); + +beforeAll(async () => { + setConfig(TEST_SITE_CONFIG as SiteConfig); + globalThis.fetch = mockFetch; +}); + +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) { + describe(`response status ${status}`, () => { + it(`does not retry when Retry-After is absent`, async () => { + mockResponseOnce("", { status }); + await expect(fetchApi("")).rejects.toMatchObject({ + response: { status }, + }); + }); + + it("does not retry when Retry-After contains empty string", async () => { + mockResponseOnce("", { + headers: { "Retry-After": "" }, + status, + }); + await expect(fetchApi("")).rejects.toMatchObject({ + response: { status }, + }); + }); + + if (NO_RETRY_STATUS_CODES.includes(status)) { + it("does not retry when Retry-After has content", async () => { + mockSetTimeout.mockClear(); + mockResponseOnce("", { + headers: { "Retry-After": "345" }, + status, + }); + await expect(fetchApi("")).rejects.toMatchObject({ + response: { status }, + }); + }); + } else { + it("retries based on Retry-After when it has content", async () => { + mockSetTimeout.mockClear(); + mockResponseOnce("", { + headers: { "Retry-After": "345" }, + status, + }); + 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); + }); + } + }); + } + + describe("other errors", () => { + it("retries on fetch failure", async () => { + 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/jest.config.js b/jest.config.js index 057d021e5..dd45681c5 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: [ 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", + ], }); diff --git a/package-lock.json b/package-lock.json index daf1a7782..5afdfb0c6 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", @@ -907,9 +907,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" @@ -943,6 +943,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": { @@ -951,6 +952,9 @@ }, "next-auth": { "optional": true + }, + "yet-another-react-lightbox": { + "optional": true } } }, diff --git a/package.json b/package.json index d5a1b0193..9edece851 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", 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/common/constants.ts b/site-config/common/constants.ts index f8223c006..d0ed54de0 100644 --- a/site-config/common/constants.ts +++ b/site-config/common/constants.ts @@ -1,5 +1,27 @@ import { BreakpointKey } from "@databiosphere/findable-ui/lib/hooks/useBreakpointHelper"; 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 ( + 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; + }, + statusCodes: KY_RETRY_STATUSES, + }, +} satisfies KyOptions; export const BREAKPOINTS: Partial = { values: { 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({