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 a failed
TLS handshake, and `ConnectionRefused` or `FailedToOpenSocket` for a name that
does not resolve. Both join `TRANSPORT_CODES` in `src/retry.ts`, so they are
retried and called transient. They stay out of `CONNECT_CODES`, so a `POST` or
`PUT` is not replayed on them, because bun also gives them when it resends a
request on a new connection. `classify` in `src/backup.ts` records a
`TimeoutError` as transient, so a stalled download is no longer `unknown`.

Not changed: a certificate bun rejects is not retried.

Model: opus-5-5
This commit is contained in:
2026-10-16 02:30:18 +00:00
parent d016dbfaa7
commit 0fb212736d
6 changed files with 233 additions and 19 deletions
+81 -3
View File
@@ -275,13 +275,15 @@ afterAll(() => {
// Run `quak backup <dir>` as `bin/quak.ts` runs it, sending its requests
// through `fetch`, and return its exit code and everything it printed on
// stderr. Its requests are retried as those of `quak backup` are, except that
// a retry does not wait. Its cache is beside `dir`, so each run of one
// directory finds the cache the run before it left, as cron's runs find the
// user's.
// a retry does not wait. A download is given up after `downloadTimeoutMs` with
// no bytes received, 60 seconds unless given. Its cache is beside `dir`, so
// each run of one directory finds the cache the run before it left, as cron's
// runs find the user's.
const backup = async (
dir: string,
opts: { verify?: boolean } = {},
fetch = server,
downloadTimeoutMs?: number,
): Promise<{ code: number; stderr: string }> => {
let printed = "";
const stderr = new Writable({
@@ -308,6 +310,7 @@ const backup = async (
loadSession(path, {
fetch,
retry: { ...UNATTENDED_RETRY_OPTIONS, sleep: async () => {} },
downloadTimeoutMs,
}),
login: prompted,
prompt: prompted,
@@ -724,6 +727,81 @@ 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 download of toast.mp4 stalls: the server sends the headers and then
// never a byte of the body. Each attempt is given up after 50 ms, quak's
// own deadline for this run, with a `TimeoutError` whose message says
// only that the download stalled. The file goes into `failures.json` as a
// transient failure, as a request that ran out of time is retried.
it("records a stalled download in failures.json, as transient", async () => {
let attempts = 0;
const fetch: typeof globalThis.fetch = async (input, init) => {
if (downloadOf(input) === 200) {
attempts++;
return new Response(
new ReadableStream<Uint8Array>({
pull: () => new Promise<void>(() => {}),
}),
);
}
return server(input, init);
};
const dir = mkdtempSync(join(root, "backup-"));
const { code } = await backup(dir, {}, fetch, 50);
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: "download stalled: no bytes received for 50 ms",
});
});
// 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