The binary make install builds retries a refused or unresolvable connection (closes #206)
check / check (push) Waiting to run

Bun's `fetch`, which `~/bin/quak` runs under, rejects with a plain `Error`
that has no `cause` and a code of bun's own. Running the built binary against
local listeners gave `ConnectionRefused` for a refused connection and for a
failed TLS handshake, and `ConnectionRefused` or `FailedToOpenSocket` for a
name that does not resolve. Both codes join `TRANSPORT_CODES` and
`CONNECT_CODES` in `src/retry.ts`, so they are retried, replayed, and called
transient by `classify`. A reset already came as `ECONNRESET` and a deadline
as a `TimeoutError`; the tests pin every shape found.

Judgement call: a failed TLS handshake is replayed, as no request byte was
sent.
Not changed: a certificate bun rejects is not retried.

Model: opus-5-5
This commit is contained in:
2026-10-16 01:43:48 +00:00
parent d016dbfaa7
commit 4c9a42db49
5 changed files with 201 additions and 31 deletions
+44
View File
@@ -724,6 +724,50 @@ describe("quak backup", () => {
},
);
// The download of toast.mp4 fails as bun's `fetch` reports a failure, in
// the binary `make install` builds: a plain `Error` with bun's own code
// and message, and no cause. These are what bun gave for a refused
// connection, a name that did not resolve and a reset connection. The
// file is tried as often as under Node, and goes into `failures.json`
// with that message, as a transient failure.
it.each([
[
"ConnectionRefused",
"Unable to connect. Is the computer able to access the url?",
],
["FailedToOpenSocket", "Was there a typo in the url or port?"],
[
"ECONNRESET",
"The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()",
],
])(
"records a download that failed with bun's %s in failures.json, as transient",
async (bunCode, message) => {
let attempts = 0;
const fetch: typeof globalThis.fetch = async (input, init) => {
if (downloadOf(input) === 200) {
attempts++;
throw Object.assign(new Error(message), {
code: bunCode,
errno: 0,
});
}
return server(input, init);
};
const dir = mkdtempSync(join(root, "backup-"));
const { code } = await backup(dir, {}, fetch);
expect(code).toBe(1);
expect(attempts).toBe(UNATTENDED_RETRY_OPTIONS.attempts);
const failures = readJSON(join(dir, "failures.json"));
expect(failures.files["200"]).toMatchObject({
classification: "transient",
error: message,
});
},
);
// The name of Ente's API server does not resolve when quak fetches the ML
// data, after every original has been backed up. No file has ML data
// cached yet, so each goes into `failures.json` with the reason, as a
+82
View File
@@ -77,6 +77,14 @@ const recordingSleep = (): {
const errnoError = (code: string, message = code): Error =>
Object.assign(new Error(message), { code });
/**
* An error shaped like what bun's `fetch` rejects with for a failure below
* HTTP: a plain `Error` with bun's own message, a `code`, an `errno` of 0, and
* no `cause`.
*/
const bunError = (code: string, message: string): Error =>
Object.assign(new Error(message), { code, errno: 0 });
// ---------------------------------------------------------------------------
// Classification
// ---------------------------------------------------------------------------
@@ -244,6 +252,80 @@ describe("isRetryable: transport failures", () => {
});
});
describe("isRetryable and isSafeToReplay: bun's fetch errors", () => {
// The binary `make install` builds runs quak under bun, whose `fetch`
// does not reject as Node's does. Each shape here was found by running
// that binary, built with bun 1.3.13, against listeners on 127.0.0.1 and
// a name under `.invalid`.
it("retries and replays a refused connection", () => {
// Bun gives the same error when the TLS handshake fails. The TCP
// connection existed then, but the request is only sent once the
// handshake is done, so replaying it is as safe.
const refused = bunError(
"ConnectionRefused",
"Unable to connect. Is the computer able to access the url?",
);
expect(isRetryable(refused)).toBe(true);
expect(isSafeToReplay(refused)).toBe(true);
});
it("retries and replays a name that does not resolve, with either code bun gives", () => {
// For the same name bun gave `ConnectionRefused` on some requests and
// `FailedToOpenSocket` on others.
for (const unresolved of [
bunError(
"ConnectionRefused",
"Unable to connect. Is the computer able to access the url?",
),
bunError(
"FailedToOpenSocket",
"Was there a typo in the url or port?",
),
]) {
expect(isRetryable(unresolved)).toBe(true);
expect(isSafeToReplay(unresolved)).toBe(true);
}
});
it("retries a connection closed before the response ended, and does not replay it", () => {
// Bun gives Node's errno, `ECONNRESET`, for a reset and for a close,
// before the response or part-way through the body. The request may
// already have reached the server.
const reset = bunError(
"ECONNRESET",
"The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()",
);
expect(isRetryable(reset)).toBe(true);
expect(isSafeToReplay(reset)).toBe(false);
});
it("retries a request that ran out of time, and does not replay it", () => {
// What bun's fetch rejects with when `AbortSignal.timeout()`, which
// bounds each JSON request, fires.
const timedOut = new DOMException(
"The operation timed out.",
"TimeoutError",
);
expect(isRetryable(timedOut)).toBe(true);
expect(isSafeToReplay(timedOut)).toBe(false);
});
it("does not retry a certificate bun rejects", () => {
// A certificate that fails verification fails the same way however
// often it is asked. Under Node it is retried only because every
// `TypeError` is.
expect(
isRetryable(
bunError(
"DEPTH_ZERO_SELF_SIGNED_CERT",
"self signed certificate",
),
),
).toBe(false);
});
});
describe("isRetryable: stream truncation versus corruption", () => {
it("retries a truncated stream", () => {
// Truncation is a transfer that stopped early. The bytes that did