diff --git a/packages/api/src/utils/client/fetch.ts b/packages/api/src/utils/client/fetch.ts index 9e98c61a91da..2cdd98a27af0 100644 --- a/packages/api/src/utils/client/fetch.ts +++ b/packages/api/src/utils/client/fetch.ts @@ -17,7 +17,7 @@ export function isFetchError(e: unknown): e is FetchError { return e instanceof FetchError; } -export type FetchErrorType = "failed" | "input" | "aborted" | "unknown"; +export type FetchErrorType = "failed" | "input" | "aborted" | "timeout" | "unknown"; type FetchErrorCause = NativeFetchFailedError["cause"] | NativeFetchInputError["cause"]; @@ -42,6 +42,10 @@ export class FetchError extends Error { super(`Request to ${url.toString()} was aborted`); this.type = "aborted"; this.code = "ERR_ABORTED"; + } else if (isNativeFetchTimeoutError(e)) { + super(`Request to ${url.toString()} timed out`); + this.type = "timeout"; + this.code = "ERR_TIMEOUT"; } else { super((e as Error).message); this.type = "unknown"; @@ -120,6 +124,15 @@ type NativeFetchAbortError = DOMException & { name: "AbortError"; }; +/** + * ``` + * DOMException [TimeoutError]: The operation was aborted due to timeout + * ``` + */ +type NativeFetchTimeoutError = DOMException & { + name: "TimeoutError"; +}; + function isNativeFetchError(e: unknown): e is NativeFetchError { return e instanceof TypeError && (e as NativeFetchError).cause instanceof Error; } @@ -133,5 +146,9 @@ function isNativeFetchInputError(e: unknown): e is NativeFetchInputError { } function isNativeFetchAbortError(e: unknown): e is NativeFetchAbortError { - return e instanceof DOMException && e.name === "AbortError"; + return e instanceof DOMException && (e as NativeFetchAbortError).name === "AbortError"; +} + +function isNativeFetchTimeoutError(e: unknown): e is NativeFetchTimeoutError { + return e instanceof DOMException && (e as NativeFetchTimeoutError).name === "TimeoutError"; } diff --git a/packages/api/test/unit/client/fetch.test.ts b/packages/api/test/unit/client/fetch.test.ts index bab1c57b2ab0..e0f87e1c57e2 100644 --- a/packages/api/test/unit/client/fetch.test.ts +++ b/packages/api/test/unit/client/fetch.test.ts @@ -11,8 +11,7 @@ describe("FetchError", function () { id: string; url?: string; requestListener?: http.RequestListener; - abort?: true; - timeout?: number; + signalHandler?: () => AbortSignal; errorType: FetchErrorType; errorCode: string; expectCause: boolean; @@ -66,14 +65,24 @@ describe("FetchError", function () { }, { id: "Aborted request", - abort: true, requestListener: () => { // leave the request open until aborted }, + signalHandler: () => AbortSignal.abort(), errorType: "aborted", errorCode: "ERR_ABORTED", expectCause: false, }, + { + id: "Timeout request", + requestListener: () => { + // leave the request open until timeout + }, + signalHandler: () => AbortSignal.timeout(10), + errorType: "timeout", + errorCode: "ERR_TIMEOUT", + expectCause: false, + }, ]; const afterHooks: (() => Promise)[] = []; @@ -90,7 +99,7 @@ describe("FetchError", function () { }); for (const testCase of testCases) { - const {id, url = `http://localhost:${port}`, requestListener, abort} = testCase; + const {id, url = `http://localhost:${port}`, requestListener, signalHandler} = testCase; it(id, async function () { if (requestListener) { @@ -107,9 +116,7 @@ describe("FetchError", function () { ); } - const controller = new AbortController(); - if (abort) setTimeout(() => controller.abort(), 20); - await expect(fetch(url, {signal: controller.signal})).to.be.rejected.then((error: FetchError) => { + await expect(fetch(url, {signal: signalHandler?.()})).to.be.rejected.then((error: FetchError) => { expect(error.type).to.be.equal(testCase.errorType); expect(error.code).to.be.equal(testCase.errorCode); if (testCase.expectCause) {