diff --git a/_tools/node_test_runner/run_test.mjs b/_tools/node_test_runner/run_test.mjs index 050d72080cc4..5b60a064ea2f 100644 --- a/_tools/node_test_runner/run_test.mjs +++ b/_tools/node_test_runner/run_test.mjs @@ -73,6 +73,7 @@ import "../../fs/unstable_lstat_test.ts"; import "../../fs/unstable_chmod_test.ts"; import "../../fs/unstable_umask_test.ts"; import "../../fs/unstable_utime_test.ts"; +import "../../http/unstable_is_retriable_fetch_error_test.ts"; import "../../internal/assertion_state_test.ts"; import "../../path/_common/assert_path_test.ts"; import "../../path/_common/basename_test.ts"; diff --git a/http/deno.json b/http/deno.json index a73e8d1335ad..940adfd58976 100644 --- a/http/deno.json +++ b/http/deno.json @@ -10,6 +10,7 @@ "./unstable-formdata-decoder-stream": "./unstable_formdata_decoder_stream.ts", "./unstable-formdata-encoder-stream": "./unstable_formdata_encoder_stream.ts", "./unstable-header": "./unstable_header.ts", + "./unstable-is-retriable-fetch-error": "./unstable_is_retriable_fetch_error.ts", "./unstable-method": "./unstable_method.ts", "./unstable-problem-details": "./unstable_problem_details.ts", "./negotiation": "./negotiation.ts", diff --git a/http/unstable_is_retriable_fetch_error.ts b/http/unstable_is_retriable_fetch_error.ts new file mode 100644 index 000000000000..c76e607c349c --- /dev/null +++ b/http/unstable_is_retriable_fetch_error.ts @@ -0,0 +1,157 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +// This module is browser compatible. + +/** Maximum number of objects examined along the `cause` chain. */ +const MAX_DEPTH = 8; + +/** + * Exact failed-fetch `TypeError` messages: "fetch failed" is shared by + * undici (Node.js) and Deno 2.9+; the rest are browser strings. + * Matching is case-sensitive on purpose: Deno's "Fetch failed: " + * (capital F, policy failures such as blocked ports) is a near-collision + * that must stay excluded because those failures are deterministic on + * retry. + */ +const FAILED_FETCH_MESSAGES = [ + "fetch failed", + "Failed to fetch", + "NetworkError when attempting to fetch resource.", + "Load failed", +]; + +/** + * Message prefix of transport failures in Deno before 2.9, e.g. + * "error sending request for url (...): ...". + */ +const LEGACY_DENO_MESSAGE_PREFIX = "error sending request"; + +/** + * `code` values of transient network failures. Bun's fetch reports + * transport failures through `code` rather than a fixed message (the DNS + * failure message even embeds the hostname): "ConnectionRefused" and + * "Timeout" are Bun's labels, the errno-style names are shared with + * Node.js, which also exposes them on the `cause` of undici's + * "fetch failed" TypeError. Deterministic codes such as "ERR_INVALID_URL" + * are excluded on purpose. + */ +const TRANSIENT_NETWORK_CODES = new Set([ + "ConnectionRefused", + "Timeout", + "ECONNREFUSED", + "ECONNRESET", + "ETIMEDOUT", + "ENOTFOUND", + "EAI_AGAIN", +]); + +function isRetriableStatus(status: number): boolean { + return status === 408 || status === 429 || + (status >= 500 && status < 600); +} + +function isRetriableValue(value: object): boolean { + if (value instanceof Response) { + return isRetriableStatus(value.status); + } + if (value instanceof DOMException) { + return value.name === "TimeoutError"; + } + if ( + value instanceof TypeError && + (FAILED_FETCH_MESSAGES.includes(value.message) || + value.message.startsWith(LEGACY_DENO_MESSAGE_PREFIX)) + ) { + return true; + } + if (value instanceof Error) { + const { status, code } = value as { status?: unknown; code?: unknown }; + if (typeof code === "string" && TRANSIENT_NETWORK_CODES.has(code)) { + return true; + } + if ( + typeof status === "number" && Number.isInteger(status) && + status >= 100 && status <= 599 + ) { + return isRetriableStatus(status); + } + } + return false; +} + +/** + * Returns `true` if the given thrown value looks like a transient fetch + * failure that is worth retrying, `false` otherwise. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * The value and its `cause` chain are examined (up to 8 objects, + * cycle-safe). A value classifies as retriable if it is any of: + * + * - a {@linkcode Response} with status 408, 429, or 5xx, including + * nonstandard 5xx codes such as Cloudflare's 522, + * - an {@linkcode Error} with an integer `status` property in the range + * 100-599, using the same status test, + * - an {@linkcode Error} whose `code` names a transient network failure: + * "ConnectionRefused" or "Timeout" (Bun), or the errno-style + * "ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "ENOTFOUND", or + * "EAI_AGAIN" (Bun and Node.js, including the `cause` of undici's + * "fetch failed" TypeError), + * - a `DOMException` named `TimeoutError`, as thrown by + * `AbortSignal.timeout()`, + * - a {@linkcode TypeError} whose message exactly matches a known + * failed-fetch message ("fetch failed" on Node.js and Deno 2.9+; + * "Failed to fetch", "NetworkError when attempting to fetch resource.", + * and "Load failed" in browsers) or starts with "error sending request" + * (Deno before 2.9). + * + * Everything else returns `false`. In particular `AbortError` is not + * retriable (aborting is caller intent), and neither are Deno's + * "Fetch failed: " policy errors, which fail deterministically on + * retry. + * + * Note that `fetch()` resolves on 4xx/5xx responses instead of throwing, + * so status classification only applies when the caller throws the + * {@linkcode Response} (or an error carrying a `status` property). Errors + * that keep the response on a `response` property instead, such as those + * thrown by the ky and got packages, are not covered. + * + * @param error The thrown value to classify. + * @returns `true` if the value indicates a retriable fetch failure, + * `false` otherwise. + * + * @example Use as the `isRetriable` hook of `retry()` + * ```ts ignore + * import { retry } from "@std/async/retry"; + * import { isRetriableFetchError } from "@std/http/unstable-is-retriable-fetch-error"; + * + * const data = await retry(async () => { + * const res = await fetch("https://example.com/api"); + * if (!res.ok) throw res; + * return await res.json(); + * }, { isRetriable: isRetriableFetchError }); + * ``` + * + * @example Classifying thrown values + * ```ts + * import { isRetriableFetchError } from "@std/http/unstable-is-retriable-fetch-error"; + * import { assert, assertFalse } from "@std/assert"; + * + * assert(isRetriableFetchError(new Response(null, { status: 503 }))); + * assert(isRetriableFetchError(new TypeError("fetch failed"))); + * assert(isRetriableFetchError(new DOMException("Timed out", "TimeoutError"))); + * assertFalse(isRetriableFetchError(new Response(null, { status: 404 }))); + * assertFalse(isRetriableFetchError(new DOMException("Aborted", "AbortError"))); + * ``` + */ +export function isRetriableFetchError(error: unknown): boolean { + const seen = new Set(); + let current: unknown = error; + for (let depth = 0; depth < MAX_DEPTH; depth++) { + if (typeof current !== "object" || current === null) return false; + if (seen.has(current)) return false; + seen.add(current); + if (isRetriableValue(current)) return true; + current = (current as { cause?: unknown }).cause; + } + return false; +} diff --git a/http/unstable_is_retriable_fetch_error_test.ts b/http/unstable_is_retriable_fetch_error_test.ts new file mode 100644 index 000000000000..909181074cee --- /dev/null +++ b/http/unstable_is_retriable_fetch_error_test.ts @@ -0,0 +1,225 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +import { + assert, + assertEquals, + assertFalse, + assertInstanceOf, +} from "@std/assert"; +import { createServer } from "node:net"; +import { isRetriableFetchError } from "./unstable_is_retriable_fetch_error.ts"; + +Deno.test("isRetriableFetchError() returns true for responses with retriable statuses", () => { + for (const status of [408, 429, 500, 503, 522]) { + assert( + isRetriableFetchError(new Response(null, { status })), + `status ${status}`, + ); + } +}); + +Deno.test("isRetriableFetchError() returns false for responses with non-retriable statuses", () => { + for (const status of [200, 400, 404]) { + assertFalse( + isRetriableFetchError(new Response(null, { status })), + `status ${status}`, + ); + } +}); + +Deno.test("isRetriableFetchError() returns true for errors with a retriable status property", () => { + for (const status of [408, 429, 500, 503, 522]) { + const error = Object.assign(new Error("request failed"), { status }); + assert(isRetriableFetchError(error), `status ${status}`); + } +}); + +Deno.test("isRetriableFetchError() returns false for errors with a non-retriable status property", () => { + for (const status of [400, 404]) { + const error = Object.assign(new Error("request failed"), { status }); + assertFalse(isRetriableFetchError(error), `status ${status}`); + } +}); + +Deno.test("isRetriableFetchError() ignores non-integer and out-of-range status properties", () => { + assertFalse( + isRetriableFetchError(Object.assign(new Error("x"), { status: "503" })), + ); + assertFalse( + isRetriableFetchError(Object.assign(new Error("x"), { status: 503.5 })), + ); + assertFalse( + isRetriableFetchError(Object.assign(new Error("x"), { status: 999 })), + ); +}); + +Deno.test("isRetriableFetchError() returns false when the message merely mentions a status or timeout", () => { + assertFalse( + isRetriableFetchError( + new Error('Unexpected response payload: {"code":429,"error":"timeout"}'), + ), + ); + assertFalse( + isRetriableFetchError( + new TypeError("Cannot read properties of undefined (reading 'timeout')"), + ), + ); +}); + +Deno.test("isRetriableFetchError() returns false for AbortError produced by AbortController.abort()", () => { + const controller = new AbortController(); + controller.abort(); + const reason = controller.signal.reason; + assertInstanceOf(reason, DOMException); + assertEquals(reason.name, "AbortError"); + assertFalse(isRetriableFetchError(reason)); +}); + +Deno.test("isRetriableFetchError() returns true for a TimeoutError DOMException", () => { + assert( + isRetriableFetchError(new DOMException("Signal timed out", "TimeoutError")), + ); +}); + +Deno.test("isRetriableFetchError() returns true for a real failed fetch TypeError", async () => { + // Find a port that is guaranteed to refuse connections: bind an + // ephemeral port on 127.0.0.1, read it, and close the listener. + const port = await new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address() as { port: number }; + server.close((error) => error ? reject(error) : resolve(address.port)); + }); + }); + const error = await fetch(`http://127.0.0.1:${port}/`).then( + () => null, + (error) => error, + ); + assertInstanceOf(error, TypeError); + const { code } = error as { code?: unknown }; + assert( + isRetriableFetchError(error), + `unexpected failed-fetch shape: name=${error.name} message=${ + JSON.stringify(error.message) + } code=${JSON.stringify(code)}`, + ); +}); + +Deno.test("isRetriableFetchError() returns true for legacy Deno transport TypeErrors", () => { + assert( + isRetriableFetchError( + new TypeError( + "error sending request for url (http://example.com/): client error (Connect): tcp connect error", + ), + ), + ); +}); + +Deno.test("isRetriableFetchError() returns true for errors with a transient network code", () => { + // Shapes thrown by Bun's fetch, verified against Bun 1.4. + const bunShapes = [ + Object.assign( + new TypeError( + "Unable to connect. Is the computer able to access the url?", + ), + { code: "ConnectionRefused" }, + ), + Object.assign( + new TypeError( + "The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()", + ), + { code: "ECONNRESET" }, + ), + Object.assign( + new TypeError("getaddrinfo ENOTFOUND example.invalid"), + { code: "ENOTFOUND" }, + ), + ]; + for (const error of bunShapes) { + assert(isRetriableFetchError(error), String(error.code)); + } + // The cause of undici's "fetch failed" TypeError, as rethrown directly. + const undiciCause = Object.assign( + new Error("connect ECONNREFUSED 127.0.0.1:80"), + { code: "ECONNREFUSED", errno: -61, syscall: "connect" }, + ); + assert(isRetriableFetchError(undiciCause)); +}); + +Deno.test("isRetriableFetchError() ignores deterministic, unknown, and non-string codes", () => { + assertFalse( + isRetriableFetchError( + Object.assign(new TypeError("Failed to parse URL from :bad:"), { + code: "ERR_INVALID_URL", + }), + ), + ); + assertFalse( + isRetriableFetchError( + Object.assign(new Error("connection refused"), { code: 111 }), + ), + ); +}); + +Deno.test("isRetriableFetchError() ignores the Bun connection-refused message without the code", () => { + assertFalse( + isRetriableFetchError( + new TypeError( + "Unable to connect. Is the computer able to access the url?", + ), + ), + ); +}); + +Deno.test("isRetriableFetchError() returns true for browser failed-fetch TypeErrors", () => { + const messages = [ + "Failed to fetch", + "NetworkError when attempting to fetch resource.", + "Load failed", + ]; + for (const message of messages) { + assert(isRetriableFetchError(new TypeError(message)), message); + } +}); + +Deno.test("isRetriableFetchError() returns false for Deno policy fetch TypeErrors", () => { + assertFalse( + isRetriableFetchError( + new TypeError("Fetch failed: Requests to port 6000 are blocked"), + ), + ); +}); + +Deno.test("isRetriableFetchError() follows nested cause chains", () => { + const error = new Error("request failed", { + cause: new Error("wrapped", { cause: new TypeError("fetch failed") }), + }); + assert(isRetriableFetchError(error)); +}); + +Deno.test("isRetriableFetchError() terminates on a cyclic cause chain", () => { + const a = new Error("a"); + const b = new Error("b", { cause: a }); + a.cause = b; + assertFalse(isRetriableFetchError(a)); +}); + +Deno.test("isRetriableFetchError() examines at most 8 objects along the cause chain", () => { + const buildChain = (wrappers: number): Error => { + let error: Error = new TypeError("fetch failed"); + for (let i = 0; i < wrappers; i++) { + error = new Error(`wrapper ${i}`, { cause: error }); + } + return error; + }; + assert(isRetriableFetchError(buildChain(7))); + assertFalse(isRetriableFetchError(buildChain(8))); +}); + +Deno.test("isRetriableFetchError() returns false for non-error values", () => { + assertFalse(isRetriableFetchError("fetch failed")); + assertFalse(isRetriableFetchError(null)); + assertFalse(isRetriableFetchError(undefined)); + assertFalse(isRetriableFetchError(503)); + assertFalse(isRetriableFetchError({ status: 503 })); +});