Repository navigation
feat: retry azul apis only when retry-after value is specified (#4561) #4993
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ed9a464
b5b314c
fd18589
ab0234e
16e27b1
c217b69
af96c02
9517d14
dddc3af
990e232
94cf37a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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]; | ||
| 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); | ||
| } | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This could be just
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wanted to guarantee that |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const BREAKPOINTS: Partial<Breakpoints> = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| values: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
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.