diff --git a/scripts/e2e/kitchen-sink-rpc-walk.mjs b/scripts/e2e/kitchen-sink-rpc-walk.mjs index 8e5dab87a95a..311def5c8a76 100644 --- a/scripts/e2e/kitchen-sink-rpc-walk.mjs +++ b/scripts/e2e/kitchen-sink-rpc-walk.mjs @@ -892,14 +892,10 @@ export async function fetchJson(url, options = {}) { let removeExternalAbort = () => {}; const abortPromise = externalSignal ? new Promise((_, reject) => { - const abortError = () => - externalSignal.reason instanceof Error - ? externalSignal.reason - : new Error("fetch aborted"); const onAbort = () => { - const error = abortError(); + const error = getExternalAbortReason(externalSignal); controller.abort(error); - reject(new Error(error.message, { cause: error })); + reject(createExternalAbortError(externalSignal)); }; if (externalSignal.aborted) { onAbort(); @@ -939,7 +935,7 @@ export async function fetchJson(url, options = {}) { if (attempt >= attempts || !isRetryableTransientNetworkError(error)) { throw error; } - await delay(options.retryDelayMs ?? 250); + await delayWithAbort(options.retryDelayMs ?? 250, externalSignal); } finally { removeExternalAbort(); if (timeout) { @@ -950,6 +946,33 @@ export async function fetchJson(url, options = {}) { throw toLintErrorObject(lastError ?? new Error(`fetch ${url} failed`), "Non-Error thrown"); } +function getExternalAbortReason(signal) { + return signal?.reason instanceof Error ? signal.reason : new Error("fetch aborted"); +} + +function createExternalAbortError(signal) { + const reason = getExternalAbortReason(signal); + return new Error(reason.message, { cause: reason }); +} + +async function delayWithAbort(delayMs, signal) { + if (!signal) { + await delay(delayMs); + return; + } + if (signal.aborted) { + throw createExternalAbortError(signal); + } + try { + await delay(delayMs, undefined, { signal }); + } catch (error) { + if (signal.aborted) { + throw createExternalAbortError(signal); + } + throw error; + } +} + export async function readBoundedResponseText(response, byteLimit, timeoutPromise) { const resolvedByteLimit = byteLimit ?? resolveKitchenSinkRpcConfig().fetchBodyMaxBytes; const contentLength = response.headers?.get?.("content-length"); diff --git a/test/scripts/kitchen-sink-rpc-walk.test.ts b/test/scripts/kitchen-sink-rpc-walk.test.ts index 2f7968273a56..3e6366dc08e7 100644 --- a/test/scripts/kitchen-sink-rpc-walk.test.ts +++ b/test/scripts/kitchen-sink-rpc-walk.test.ts @@ -1815,6 +1815,30 @@ describe("kitchen-sink RPC process sampling", () => { expect(fetchImpl).toHaveBeenCalledTimes(2); }); + it("aborts HTTP probe retry backoff when the external signal fires", async () => { + const controller = new AbortController(); + const reset = new TypeError("fetch failed", { + cause: Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" }), + }); + const fetchImpl = vi.fn().mockRejectedValue(reset); + const startedAt = Date.now(); + + setTimeout(() => { + controller.abort(new Error("gateway exited before ready")); + }, 25); + + await expect( + fetchJson("http://127.0.0.1:19680/healthz", { + attempts: 2, + fetchImpl, + retryDelayMs: 5_000, + signal: controller.signal, + }), + ).rejects.toThrow("gateway exited before ready"); + expect(fetchImpl).toHaveBeenCalledOnce(); + expect(Date.now() - startedAt).toBeLessThan(500); + }); + it("bounds HTTP probe response bodies", async () => { const fetchImpl = vi.fn().mockResolvedValue(new Response("x".repeat(1025), { status: 200 }));