Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions __tests__/site-config/azul-ky-options.test.ts
Original file line number Diff line number Diff line change
@@ -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<SiteConfig, "dataSource" | "kyOptions"> = {
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];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the slimmer config, the "absent" and "empty string" cases still make sense for every status, but "retries based on Retry-After" only needs 429 and 503, since Azul doesn't send it on 500, 502 or 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);
}
1 change: 0 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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$":
"<rootDir>/__mocks__/markdownRenderer.tsx",
"^ky$": "<rootDir>/__mocks__/ky.ts",
},
testEnvironment: "jest-environment-jsdom",
testPathIgnorePatterns: [
Expand Down
6 changes: 5 additions & 1 deletion next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
});
12 changes: 8 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion site-config/anvil-cmg/dev/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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: {
Expand Down
22 changes: 22 additions & 0 deletions site-config/common/constants.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +5 to +24

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be just shouldRetry. statusCodes is the same as Ky's default list, and Azul's OpenAPI spec only documents Retry-After on 429 and 503, which are already in Ky's default afterStatusCodes. So KY_RETRY_STATUSES and both overrides can go.

Suggested change
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 AZUL_KY_OPTIONS = {
retry: {
// Azul sends Retry-After on the responses it wants retried (429 and 503), which Ky's default afterStatusCodes already cover.
// Never retry any other response, and keep the default for network errors.
shouldRetry({ error }): boolean | undefined {
return error instanceof HTTPError &&
!error.response.headers.get("Retry-After")
? false
: undefined;
},
},
} satisfies KyOptions;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to guarantee that Retry-After will be used even if something changes on the Azul end


export const BREAKPOINTS: Partial<Breakpoints> = {
values: {
Expand Down
3 changes: 2 additions & 1 deletion site-config/hca-dcp/ma-dev/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion site-config/lungmap/dev/config.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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({
Expand Down
Loading