-
-
Notifications
You must be signed in to change notification settings - Fork 306
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor: switch to native fetch implementation (#5811)
* Switch to native fetch implementation * Wrapper around native fetch to improve error handling * Improve handling of other native fetch errors * Update fetch error types * Native fetch errors might not have cause.code property * Check if instance of TypeError * Add unknown scheme error to examples * Add tests to detect changes native fetch error behavior * Remove unnecessary exports * Improve error.cause type * Move fetch to @lodestar/api package * Remove tsdoc from NativeFetchError * Add headers overflow test
- Loading branch information
Showing
19 changed files
with
278 additions
and
39 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,137 @@ | ||
/** | ||
* Native fetch with transparent and consistent error handling | ||
* | ||
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/fetch) | ||
*/ | ||
async function wrappedFetch(url: string | URL, init?: RequestInit): Promise<Response> { | ||
try { | ||
return await fetch(url, init); | ||
} catch (e) { | ||
throw new FetchError(url, e); | ||
} | ||
} | ||
|
||
export {wrappedFetch as fetch}; | ||
|
||
export function isFetchError(e: unknown): e is FetchError { | ||
return e instanceof FetchError; | ||
} | ||
|
||
export type FetchErrorType = "failed" | "input" | "aborted" | "unknown"; | ||
|
||
type FetchErrorCause = NativeFetchFailedError["cause"] | NativeFetchInputError["cause"]; | ||
|
||
export class FetchError extends Error { | ||
type: FetchErrorType; | ||
code: string; | ||
cause?: FetchErrorCause; | ||
|
||
constructor(url: string | URL, e: unknown) { | ||
if (isNativeFetchFailedError(e)) { | ||
super(`Request to ${url.toString()} failed, reason: ${e.cause.message}`); | ||
this.type = "failed"; | ||
this.code = e.cause.code || "ERR_FETCH_FAILED"; | ||
this.cause = e.cause; | ||
} else if (isNativeFetchInputError(e)) { | ||
// For input errors the e.message is more detailed | ||
super(e.message); | ||
this.type = "input"; | ||
this.code = e.cause.code || "ERR_INVALID_INPUT"; | ||
this.cause = e.cause; | ||
} else if (isNativeFetchAbortError(e)) { | ||
super(`Request to ${url.toString()} was aborted`); | ||
this.type = "aborted"; | ||
this.code = "ERR_ABORTED"; | ||
} else { | ||
super((e as Error).message); | ||
this.type = "unknown"; | ||
this.code = "ERR_UNKNOWN"; | ||
} | ||
this.name = this.constructor.name; | ||
} | ||
} | ||
|
||
type NativeFetchError = TypeError & { | ||
cause: Error & { | ||
code?: string; | ||
}; | ||
}; | ||
|
||
/** | ||
* ``` | ||
* TypeError: fetch failed | ||
* cause: Error: connect ECONNREFUSED 127.0.0.1:9596 | ||
* errno: -111, | ||
* code: 'ECONNREFUSED', | ||
* syscall: 'connect', | ||
* address: '127.0.0.1', | ||
* port: 9596 | ||
* --------------------------- | ||
* TypeError: fetch failed | ||
* cause: Error: getaddrinfo ENOTFOUND non-existent-domain | ||
* errno: -3008, | ||
* code: 'ENOTFOUND', | ||
* syscall: 'getaddrinfo', | ||
* hostname: 'non-existent-domain' | ||
* --------------------------- | ||
* TypeError: fetch failed | ||
* cause: SocketError: other side closed | ||
* code: 'UND_ERR_SOCKET', | ||
* socket: {} | ||
* --------------------------- | ||
* TypeError: fetch failed | ||
* cause: Error: unknown scheme | ||
* [cause]: undefined | ||
* ``` | ||
*/ | ||
type NativeFetchFailedError = NativeFetchError & { | ||
message: "fetch failed"; | ||
cause: { | ||
errno?: string; | ||
syscall?: string; | ||
address?: string; | ||
port?: string; | ||
hostname?: string; | ||
socket?: object; | ||
[prop: string]: unknown; | ||
}; | ||
}; | ||
|
||
/** | ||
* ``` | ||
* TypeError: Failed to parse URL from invalid-url | ||
* [cause]: TypeError [ERR_INVALID_URL]: Invalid URL | ||
* input: 'invalid-url', | ||
* code: 'ERR_INVALID_URL' | ||
* ``` | ||
*/ | ||
type NativeFetchInputError = NativeFetchError & { | ||
cause: { | ||
input: unknown; | ||
}; | ||
}; | ||
|
||
/** | ||
* ``` | ||
* DOMException [AbortError]: This operation was aborted | ||
* ``` | ||
*/ | ||
type NativeFetchAbortError = DOMException & { | ||
name: "AbortError"; | ||
}; | ||
|
||
function isNativeFetchError(e: unknown): e is NativeFetchError { | ||
return e instanceof TypeError && (e as NativeFetchError).cause instanceof Error; | ||
} | ||
|
||
function isNativeFetchFailedError(e: unknown): e is NativeFetchFailedError { | ||
return isNativeFetchError(e) && (e as NativeFetchFailedError).message === "fetch failed"; | ||
} | ||
|
||
function isNativeFetchInputError(e: unknown): e is NativeFetchInputError { | ||
return isNativeFetchError(e) && (e as NativeFetchInputError).cause.input !== undefined; | ||
} | ||
|
||
function isNativeFetchAbortError(e: unknown): e is NativeFetchAbortError { | ||
return e instanceof DOMException && e.name === "AbortError"; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,3 @@ | ||
export * from "./client.js"; | ||
export * from "./httpClient.js"; | ||
export * from "./fetch.js"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
import crypto from "node:crypto"; | ||
import http from "node:http"; | ||
import {expect} from "chai"; | ||
import {FetchError, FetchErrorType, fetch} from "../../../src/utils/client/fetch.js"; | ||
|
||
describe("FetchError", function () { | ||
const port = 37421; | ||
const randomHex = crypto.randomBytes(32).toString("hex"); | ||
|
||
const testCases: { | ||
id: string; | ||
url?: string; | ||
requestListener?: http.RequestListener; | ||
abort?: true; | ||
timeout?: number; | ||
errorType: FetchErrorType; | ||
errorCode: string; | ||
expectCause: boolean; | ||
}[] = [ | ||
{ | ||
id: "Bad domain", | ||
// Use random bytes to ensure no collisions | ||
url: `https://${randomHex}.infura.io`, | ||
errorType: "failed", | ||
errorCode: "ENOTFOUND", | ||
expectCause: true, | ||
}, | ||
{ | ||
id: "Bad port", | ||
url: `http://localhost:${port + 1}`, | ||
requestListener: (_req, res) => res.end(), | ||
errorType: "failed", | ||
errorCode: "ECONNREFUSED", | ||
expectCause: true, | ||
}, | ||
{ | ||
id: "Socket error", | ||
requestListener: (_req, res) => res.socket?.destroy(), | ||
errorType: "failed", | ||
errorCode: "UND_ERR_SOCKET", | ||
expectCause: true, | ||
}, | ||
{ | ||
id: "Headers overflow", | ||
requestListener: (_req, res) => { | ||
res.setHeader("Large-Header", "a".repeat(1e6)); | ||
res.end(); | ||
}, | ||
errorType: "failed", | ||
errorCode: "UND_ERR_HEADERS_OVERFLOW", | ||
expectCause: true, | ||
}, | ||
{ | ||
id: "Unknown scheme", | ||
url: `httsp://localhost:${port}`, | ||
errorType: "failed", | ||
errorCode: "ERR_FETCH_FAILED", | ||
expectCause: true, | ||
}, | ||
{ | ||
id: "Invalid URL", | ||
url: "invalid-url", | ||
errorType: "input", | ||
errorCode: "ERR_INVALID_URL", | ||
expectCause: true, | ||
}, | ||
{ | ||
id: "Aborted request", | ||
abort: true, | ||
requestListener: () => { | ||
// leave the request open until aborted | ||
}, | ||
errorType: "aborted", | ||
errorCode: "ERR_ABORTED", | ||
expectCause: false, | ||
}, | ||
]; | ||
|
||
const afterHooks: (() => Promise<void>)[] = []; | ||
|
||
afterEach(async function () { | ||
while (afterHooks.length) { | ||
const afterHook = afterHooks.pop(); | ||
if (afterHook) | ||
await afterHook().catch((e: Error) => { | ||
// eslint-disable-next-line no-console | ||
console.error("Error in afterEach hook", e); | ||
}); | ||
} | ||
}); | ||
|
||
for (const testCase of testCases) { | ||
const {id, url = `http://localhost:${port}`, requestListener, abort} = testCase; | ||
|
||
it(id, async function () { | ||
if (requestListener) { | ||
const server = http.createServer(requestListener); | ||
await new Promise<void>((resolve) => server.listen(port, resolve)); | ||
afterHooks.push( | ||
() => | ||
new Promise((resolve, reject) => | ||
server.close((err) => { | ||
if (err) reject(err); | ||
else resolve(); | ||
}) | ||
) | ||
); | ||
} | ||
|
||
const controller = new AbortController(); | ||
if (abort) setTimeout(() => controller.abort(), 20); | ||
await expect(fetch(url, {signal: controller.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) { | ||
expect(error.cause).to.be.instanceof(Error); | ||
} | ||
}); | ||
}); | ||
} | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.