Repository navigation
retry subdomain requests to be more resilient to flakes #10832
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
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,5 @@ | ||
| --- | ||
| "wrangler": patch | ||
| --- | ||
|
|
||
| retry subdomain requests to be more resilient to flakes |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,17 @@ | ||
| import { logger } from "../../logger"; | ||
| import { APIError } from "../../parse"; | ||
| import { retryOnAPIFailure } from "../../utils/retry"; | ||
| import { mockConsoleMethods } from "../helpers/mock-console"; | ||
|
|
||
| describe("retryOnAPIFailure", () => { | ||
| const std = mockConsoleMethods(); | ||
|
|
||
| beforeEach(() => { | ||
| const level = logger.loggerLevel; | ||
| logger.loggerLevel = "debug"; | ||
| return () => (logger.loggerLevel = level); | ||
|
Contributor
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. Oh nice, I didn't know about that way to restore values |
||
| }); | ||
|
|
||
| it("should retry 5xx errors and succeed if the 3rd try succeeds", async () => { | ||
| let attempts = 0; | ||
|
|
||
|
|
@@ -15,15 +22,13 @@ describe("retryOnAPIFailure", () => { | |
| } | ||
| }); | ||
| expect(attempts).toBe(3); | ||
| expect(std).toMatchInlineSnapshot(` | ||
| Object { | ||
| "debug": "", | ||
| "err": "", | ||
| "info": "Retrying API call after error... | ||
| Retrying API call after error...", | ||
| "out": "", | ||
| "warn": "", | ||
| } | ||
| expect(getRetryAndErrorLogs(std.debug)).toMatchInlineSnapshot(` | ||
| Array [ | ||
| "Retrying API call after error...", | ||
| "APIError: 500 error", | ||
| "Retrying API call after error...", | ||
| "APIError: 500 error", | ||
| ] | ||
| `); | ||
| }); | ||
|
|
||
|
|
@@ -37,16 +42,15 @@ describe("retryOnAPIFailure", () => { | |
| }) | ||
| ).rejects.toMatchInlineSnapshot(`[APIError: 500 error]`); | ||
| expect(attempts).toBe(3); | ||
| expect(std).toMatchInlineSnapshot(` | ||
| Object { | ||
| "debug": "", | ||
| "err": "", | ||
| "info": "Retrying API call after error... | ||
| Retrying API call after error... | ||
| Retrying API call after error...", | ||
| "out": "", | ||
| "warn": "", | ||
| } | ||
| expect(getRetryAndErrorLogs(std.debug)).toMatchInlineSnapshot(` | ||
| Array [ | ||
| "Retrying API call after error...", | ||
| "APIError: 500 error", | ||
| "Retrying API call after error...", | ||
| "APIError: 500 error", | ||
| "Retrying API call after error...", | ||
| "APIError: 500 error", | ||
| ] | ||
| `); | ||
| }); | ||
|
|
||
|
|
@@ -60,15 +64,7 @@ describe("retryOnAPIFailure", () => { | |
| }) | ||
| ).rejects.toMatchInlineSnapshot(`[APIError: 401 error]`); | ||
| expect(attempts).toBe(1); | ||
| expect(std).toMatchInlineSnapshot(` | ||
| Object { | ||
| "debug": "", | ||
| "err": "", | ||
| "info": "", | ||
| "out": "", | ||
| "warn": "", | ||
| } | ||
| `); | ||
| expect(getRetryAndErrorLogs(std.debug)).toMatchInlineSnapshot(`Array []`); | ||
| }); | ||
|
|
||
| it("should retry TypeError", async () => { | ||
|
|
@@ -81,16 +77,12 @@ describe("retryOnAPIFailure", () => { | |
| }) | ||
| ).rejects.toMatchInlineSnapshot(`[TypeError: type error]`); | ||
| expect(attempts).toBe(3); | ||
| expect(std).toMatchInlineSnapshot(` | ||
| Object { | ||
| "debug": "", | ||
| "err": "", | ||
| "info": "Retrying API call after error... | ||
| Retrying API call after error... | ||
| Retrying API call after error...", | ||
| "out": "", | ||
| "warn": "", | ||
| } | ||
| expect(getRetryAndErrorLogs(std.debug)).toMatchInlineSnapshot(` | ||
| Array [ | ||
| "Retrying API call after error...", | ||
| "Retrying API call after error...", | ||
| "Retrying API call after error...", | ||
| ] | ||
| `); | ||
| }); | ||
|
|
||
|
|
@@ -104,15 +96,7 @@ describe("retryOnAPIFailure", () => { | |
| }) | ||
| ).rejects.toMatchInlineSnapshot(`[Error: some error]`); | ||
| expect(attempts).toBe(1); | ||
| expect(std).toMatchInlineSnapshot(` | ||
| Object { | ||
| "debug": "", | ||
| "err": "", | ||
| "info": "", | ||
| "out": "", | ||
| "warn": "", | ||
| } | ||
| `); | ||
| expect(getRetryAndErrorLogs(std.debug)).toMatchInlineSnapshot(`Array []`); | ||
| }); | ||
|
|
||
| it("should retry custom APIError implementation with non-5xx error", async () => { | ||
|
|
@@ -134,16 +118,21 @@ describe("retryOnAPIFailure", () => { | |
| ).rejects.toMatchInlineSnapshot(`[CustomAPIError: 401 error]`); | ||
| expect(attempts).toBe(3); | ||
| expect(checkedCustomIsRetryable).toBe(true); | ||
| expect(std).toMatchInlineSnapshot(` | ||
| Object { | ||
| "debug": "", | ||
| "err": "", | ||
| "info": "Retrying API call after error... | ||
| Retrying API call after error... | ||
| Retrying API call after error...", | ||
| "out": "", | ||
| "warn": "", | ||
| } | ||
| expect(getRetryAndErrorLogs(std.debug)).toMatchInlineSnapshot(` | ||
| Array [ | ||
| "Retrying API call after error...", | ||
| "CustomAPIError: 401 error", | ||
| "Retrying API call after error...", | ||
| "CustomAPIError: 401 error", | ||
| "Retrying API call after error...", | ||
| "CustomAPIError: 401 error", | ||
| ] | ||
| `); | ||
| }); | ||
| }); | ||
|
|
||
| function getRetryAndErrorLogs(debugOutput: string): string[] { | ||
| return debugOutput | ||
| .split("\n") | ||
| .filter((line) => line.includes("Retrying") || line.includes("APIError")); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,18 +31,13 @@ export async function fetchResult<ResponseType>( | |
| abortSignal?: AbortSignal, | ||
| apiToken?: ApiCredentials | ||
| ): Promise<ResponseType> { | ||
| const json = await fetchInternal<FetchResult<ResponseType>>( | ||
| complianceConfig, | ||
| resource, | ||
| init, | ||
| queryParams, | ||
| abortSignal, | ||
| apiToken | ||
| ); | ||
| const { response: json, status } = await fetchInternal< | ||
|
Contributor
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. Side note: I really think the return type should be a cast... I mean it is but it should be explicit vs templated
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. It is tricky to make it a cast as it is the |
||
| FetchResult<ResponseType> | ||
| >(complianceConfig, resource, init, queryParams, abortSignal, apiToken); | ||
| if (json.success) { | ||
| return json.result; | ||
| } else { | ||
| throwFetchError(resource, json); | ||
| throwFetchError(resource, json, status); | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -54,7 +49,7 @@ export async function fetchGraphqlResult<ResponseType>( | |
| init: RequestInit = {}, | ||
| abortSignal?: AbortSignal | ||
| ): Promise<ResponseType> { | ||
| const json = await fetchInternal<ResponseType>( | ||
| const { response: json } = await fetchInternal<ResponseType>( | ||
| complianceConfig, | ||
| "/graphql", | ||
| { ...init, method: "POST" }, //Cloudflare API v4 doesn't allow GETs to /graphql | ||
|
|
@@ -87,12 +82,9 @@ export async function fetchListResult<ResponseType>( | |
| queryParams = new URLSearchParams(queryParams); | ||
| queryParams.set("cursor", cursor); | ||
| } | ||
| const json = await fetchInternal<FetchResult<ResponseType[]>>( | ||
| complianceConfig, | ||
| resource, | ||
| init, | ||
| queryParams | ||
| ); | ||
| const { response: json, status } = await fetchInternal< | ||
| FetchResult<ResponseType[]> | ||
| >(complianceConfig, resource, init, queryParams); | ||
| if (json.success) { | ||
| results.push(...json.result); | ||
| if (hasCursor(json.result_info)) { | ||
|
|
@@ -101,7 +93,7 @@ export async function fetchListResult<ResponseType>( | |
| getMoreResults = false; | ||
| } | ||
| } else { | ||
| throwFetchError(resource, json); | ||
| throwFetchError(resource, json, status); | ||
| } | ||
| } | ||
| return results; | ||
|
|
@@ -127,12 +119,9 @@ export async function fetchPagedListResult<ResponseType>( | |
| queryParams = new URLSearchParams(queryParams); | ||
| queryParams.set("page", String(page)); | ||
|
|
||
| const json = await fetchInternal<FetchResult<ResponseType[]>>( | ||
| complianceConfig, | ||
| resource, | ||
| init, | ||
| queryParams | ||
| ); | ||
| const { response: json, status } = await fetchInternal< | ||
| FetchResult<ResponseType[]> | ||
| >(complianceConfig, resource, init, queryParams); | ||
| if (json.success) { | ||
| results.push(...json.result); | ||
| if (hasMorePages(json.result_info)) { | ||
|
|
@@ -141,7 +130,7 @@ export async function fetchPagedListResult<ResponseType>( | |
| getMoreResults = false; | ||
| } | ||
| } else { | ||
| throwFetchError(resource, json); | ||
| throwFetchError(resource, json, status); | ||
| } | ||
| } | ||
| return results; | ||
|
|
@@ -171,7 +160,8 @@ export function hasMorePages( | |
|
|
||
| function throwFetchError( | ||
| resource: string, | ||
| response: FetchResult<unknown> | ||
| response: FetchResult<unknown>, | ||
| status: number | ||
| ): never { | ||
| // This is an error from within an MSW handler | ||
| if (typeof vitest !== "undefined" && !("errors" in response)) { | ||
|
|
@@ -187,6 +177,7 @@ function throwFetchError( | |
| ...response.errors.map((err) => ({ text: renderError(err) })), | ||
| ...(response.messages?.map((text) => ({ text })) ?? []), | ||
| ], | ||
| status, | ||
| }); | ||
| // add the first error code directly to this error | ||
| // so consumers can use it for specific behaviour | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -158,7 +158,7 @@ export async function fetchInternal<ResponseType>( | |
| queryParams?: URLSearchParams, | ||
| abortSignal?: AbortSignal, | ||
| apiToken?: ApiCredentials | ||
| ): Promise<ResponseType> { | ||
| ): Promise<{ response: ResponseType; status: number }> { | ||
| const method = init.method ?? "GET"; | ||
| const response = await performApiFetch( | ||
| complianceConfig, | ||
|
|
@@ -181,12 +181,20 @@ export async function fetchInternal<ResponseType>( | |
| // HTTP 204 and HTTP 205 responses do not return a body. We need to special-case this | ||
| // as otherwise parseJSON will throw an error back to the user. | ||
| if (!jsonText && (response.status === 204 || response.status === 205)) { | ||
| const emptyBody = `{"result": {}, "success": true, "errors": [], "messages": []}`; | ||
| return parseJSON(emptyBody) as ResponseType; | ||
| return { | ||
| response: { | ||
| result: {}, | ||
| success: true, | ||
| errors: [], | ||
| messages: [], | ||
| } as ResponseType, | ||
|
Contributor
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. would satisfies work here?
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. No because ResponseType is an unknown type parameter at this point. |
||
| status: response.status, | ||
| }; | ||
| } | ||
|
|
||
| try { | ||
| return parseJSON(jsonText) as ResponseType; | ||
| const json = parseJSON(jsonText) as ResponseType; | ||
| return { response: json, status: response.status }; | ||
| } catch { | ||
| throw new APIError({ | ||
| text: "Received a malformed response from the API", | ||
|
|
||
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.
Nit: Maybe just remove the
{ once: true }in the original handler? We are resetting handlers after each tests anyway, so I don't see a strong reason to useoncehere.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.
It is actually on purpose. It guarantees that the test will fail if more than one request hits this endpoint.