Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions _tools/node_test_runner/run_test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions http/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
157 changes: 157 additions & 0 deletions http/unstable_is_retriable_fetch_error.ts
Original file line number Diff line number Diff line change
@@ -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: <reason>"
* (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: <reason>" 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<object>();
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;
}
225 changes: 225 additions & 0 deletions http/unstable_is_retriable_fetch_error_test.ts
Original file line number Diff line number Diff line change
@@ -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<number>((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 }));
});
Loading