fix(infra): avoid detached finally unhandled rejection in fetch wrapper

This commit is contained in:
Netcup-Clawd Admin
2026-02-16 06:03:29 -05:00
committed by sebslight
parent cb391f4bdc
commit 718fa54aac
2 changed files with 80 additions and 5 deletions

View File

@@ -50,4 +50,74 @@ describe("wrapFetchWithAbortSignal", () => {
await promise;
});
it("does not emit an extra unhandled rejection when wrapped fetch rejects", async () => {
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => {
unhandled.push(reason);
};
process.on("unhandledRejection", onUnhandled);
const fetchError = new TypeError("fetch failed");
const fetchImpl = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) =>
Promise.reject(fetchError),
);
const wrapped = wrapFetchWithAbortSignal(fetchImpl);
let abortHandler: (() => void) | null = null;
const removeEventListener = vi.fn((event: string, handler: () => void) => {
if (event === "abort" && abortHandler === handler) {
abortHandler = null;
}
});
const fakeSignal = {
aborted: false,
addEventListener: (event: string, handler: () => void) => {
if (event === "abort") {
abortHandler = handler;
}
},
removeEventListener,
} as AbortSignal;
try {
await expect(wrapped("https://example.com", { signal: fakeSignal })).rejects.toBe(fetchError);
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(unhandled).toEqual([]);
expect(removeEventListener).toHaveBeenCalledOnce();
} finally {
process.off("unhandledRejection", onUnhandled);
}
});
it("cleans up listener and rethrows when fetch throws synchronously", () => {
const syncError = new TypeError("sync fetch failure");
const fetchImpl = vi.fn(() => {
throw syncError;
});
const wrapped = wrapFetchWithAbortSignal(fetchImpl);
let abortHandler: (() => void) | null = null;
const removeEventListener = vi.fn((event: string, handler: () => void) => {
if (event === "abort" && abortHandler === handler) {
abortHandler = null;
}
});
const fakeSignal = {
aborted: false,
addEventListener: (event: string, handler: () => void) => {
if (event === "abort") {
abortHandler = handler;
}
},
removeEventListener,
} as AbortSignal;
expect(() => wrapped("https://example.com", { signal: fakeSignal })).toThrow(syncError);
expect(removeEventListener).toHaveBeenCalledOnce();
});
});

View File

@@ -50,13 +50,18 @@ export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch
} else {
signal.addEventListener("abort", onAbort, { once: true });
}
const response = fetchImpl(input, { ...patchedInit, signal: controller.signal });
if (typeof signal.removeEventListener === "function") {
void response.finally(() => {
const cleanup = () => {
if (typeof signal.removeEventListener === "function") {
signal.removeEventListener("abort", onAbort);
});
}
};
try {
const response = fetchImpl(input, { ...patchedInit, signal: controller.signal });
return response.finally(cleanup);
} catch (error) {
cleanup();
throw error;
}
return response;
}) as FetchWithPreconnect;
const fetchWithPreconnect = fetchImpl as FetchWithPreconnect;