From 902cce09b170e0c2eca32d20c2646028cb497f1e Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sat, 12 Aug 2023 22:59:35 +0200 Subject: [PATCH] chore: handle native fetch timeout error --- packages/api/src/utils/client/fetch.ts | 19 ++++++++++++++- packages/api/test/unit/client/fetch.test.ts | 26 +++++++++++++++++---- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/packages/api/src/utils/client/fetch.ts b/packages/api/src/utils/client/fetch.ts index 9e98c61a91da..bc3502300b52 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; } @@ -135,3 +148,7 @@ function isNativeFetchInputError(e: unknown): e is NativeFetchInputError { function isNativeFetchAbortError(e: unknown): e is NativeFetchAbortError { return e instanceof DOMException && e.name === "AbortError"; } + +function isNativeFetchTimeoutError(e: unknown): e is NativeFetchTimeoutError { + return e instanceof DOMException && e.name === "TimeoutError"; +} diff --git a/packages/api/test/unit/client/fetch.test.ts b/packages/api/test/unit/client/fetch.test.ts index bab1c57b2ab0..bf18a6e0ab65 100644 --- a/packages/api/test/unit/client/fetch.test.ts +++ b/packages/api/test/unit/client/fetch.test.ts @@ -12,7 +12,7 @@ describe("FetchError", function () { url?: string; requestListener?: http.RequestListener; abort?: true; - timeout?: number; + timeout?: true; errorType: FetchErrorType; errorCode: string; expectCause: boolean; @@ -74,6 +74,16 @@ describe("FetchError", function () { errorCode: "ERR_ABORTED", expectCause: false, }, + { + id: "Timeout request", + timeout: true, + requestListener: () => { + // leave the request open until timeout + }, + errorType: "timeout", + errorCode: "ERR_TIMEOUT", + expectCause: false, + }, ]; const afterHooks: (() => Promise)[] = []; @@ -90,7 +100,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, abort, timeout} = testCase; it(id, async function () { if (requestListener) { @@ -107,9 +117,15 @@ 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) => { + let signal: AbortSignal | undefined; + if (abort) { + const controller = new AbortController(); + setTimeout(() => controller.abort(), 0); + signal = controller.signal; + } else if (timeout) { + signal = AbortSignal.timeout(10); + } + await expect(fetch(url, {signal})).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) {