A network failure is printed and recorded with its reason (closes #196)
check / check (push) Waiting to run

Node's `fetch` rejects every failure below HTTP as `fetch failed` and puts
the reason on `cause`. `describeError` in `src/errors.ts` gives the message
followed by the messages on the cause chain, and for an `AggregateError`,
which Node gives when every address of a host fails, the message of each
address. `run` prints it, and the backup logs and records it. `classify`
reads that text and calls a failure transient when `hasTransportCode`, now
exported by `src/retry.ts`, finds a transport errno on the chain. A failed
ML data fetch keeps its error as the cause. The bounded walk along the
chain moved from `src/retry.ts` to `src/errors.ts`.

A truncated download's message leaves the authentication failure to its
cause.

Model: opus-5-5
This commit is contained in:
2026-10-16 00:31:35 +00:00
parent 5a940d4e4e
commit 0be9ec7b32
10 changed files with 378 additions and 47 deletions
+144
View File
@@ -329,6 +329,41 @@ const downloadOf = (input: RequestInfo | URL): number | undefined => {
: undefined;
};
// The error Node gives for a connection to `address` that was refused.
const refusedAt = (address: string): Error =>
Object.assign(new Error(`connect ECONNREFUSED ${address}`), {
code: "ECONNREFUSED",
});
// What Node's `fetch` rejects with when the server refuses the connection, as
// for every failure below HTTP: a TypeError that says only "fetch failed",
// with the reason, and its errno, as its cause. When the server has several
// addresses and each one refuses, the cause is an AggregateError with an empty
// message, one error per address and the errno of the first. `reason` is the
// error as quak prints it and records it in `failures.json`.
const REFUSED = [
{
addresses: "its one address",
error: (): TypeError =>
new TypeError("fetch failed", { cause: refusedAt("1.2.3.4:443") }),
reason: "fetch failed: connect ECONNREFUSED 1.2.3.4:443",
},
{
addresses: "each of its two addresses",
error: (): TypeError =>
new TypeError("fetch failed", {
cause: Object.assign(
new AggregateError([
refusedAt("2001:db8::1:443"),
refusedAt("1.2.3.4:443"),
]),
{ code: "ECONNREFUSED" },
),
}),
reason: "fetch failed: connect ECONNREFUSED 2001:db8::1:443, connect ECONNREFUSED 1.2.3.4:443",
},
];
// The fake server, pushing onto `downloads` the ID of each file whose original
// is downloaded.
const recordingDownloads =
@@ -619,6 +654,115 @@ describe("quak backup", () => {
expect(readdirSync(dir)).toEqual([]);
});
// The server refuses every connection, at its one address or at each of
// several. quak tries each request again, here without waiting, and then
// the run stops at the refresh it starts with. The line it prints gives
// the reason Node's `fetch` put on the error's cause, so a cron log tells
// a refused connection from a name that did not resolve, and the exit
// code is 1.
it.each(REFUSED)(
"prints why the refresh could not reach the server at $addresses, and exits 1",
async ({ error, reason }) => {
const dir = mkdtempSync(join(root, "backup-"));
const fetch: typeof globalThis.fetch = async () => {
throw error();
};
const { code, stderr } = await backup(dir, {}, fetch);
expect(code).toBe(1);
expect(stderr).toBe(
"Starting backup...\n" +
"Refreshing library...\n" +
`quak: ${reason}\n`,
);
},
);
// The files CDN refuses every connection for toast.mp4, at its one
// address or at each of several. Once its retries are spent, the file
// goes into `failures.json` with the reason, as a transient failure,
// since a later run may well reach the CDN, and the lines the backup
// prints for it give the same reason. The rest of the account is backed
// up, and the exit code is 1.
it.each(REFUSED)(
"records why a download could not reach the server at $addresses in failures.json, as transient, and exits 1",
async ({ error, reason }) => {
let attempts = 0;
const fetch: typeof globalThis.fetch = async (input, init) => {
if (downloadOf(input) === 200) {
attempts++;
throw error();
}
return server(input, init);
};
const dir = mkdtempSync(join(root, "backup-"));
const { code, stderr } = await backup(dir, {}, fetch);
expect(code).toBe(1);
expect(attempts).toBe(UNATTENDED_RETRY_OPTIONS.attempts);
const failures = readJSON(join(dir, "failures.json"));
expect(Object.keys(failures.files)).toEqual(["200"]);
expect(failures.files["200"]).toMatchObject({
fileID: 200,
title: "toast.mp4",
classification: "transient",
attempts: 1,
error: reason,
});
expect(stderr).toContain(`FAILED original toast.mp4: ${reason}\n`);
expect(stderr).toContain(
`[Friend's Wedding] toast.mp4 (id 200): ${reason}\n`,
);
expect(readFileSync(join(dir, DAY, "2024-03-01.100.jpg"))).toEqual(
Buffer.from(JPEG_WITH_EXIF),
);
expect(existsSync(join(dir, DAY, "2024-03-01.200.mp4"))).toBe(
false,
);
},
);
// 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
// transient failure, as a download that could not reach the server does,
// and the exit code is 1.
it("records why the ML data fetch could not reach the server in failures.json, as transient, and exits 1", async () => {
const fetch: typeof globalThis.fetch = async (input, init) => {
if (new URL(String(input)).pathname === "/files/data/fetch") {
throw new TypeError("fetch failed", {
cause: Object.assign(
new Error("getaddrinfo ENOTFOUND api.ente.io"),
{ code: "ENOTFOUND" },
),
});
}
return server(input, init);
};
const dir = mkdtempSync(join(root, "backup-"));
const { code, stderr } = await backup(dir, {}, fetch);
expect(code).toBe(1);
const error =
"ML data: fetch failed: getaddrinfo ENOTFOUND api.ente.io";
expect(stderr).toContain(`FAILED ${error}\n`);
const failures = readJSON(join(dir, "failures.json"));
expect(
Object.values(failures.files).map((f) => [
f.fileID,
f.classification,
f.error,
]),
).toEqual([
[100, "transient", error],
[200, "transient", error],
[300, "transient", error],
]);
});
// Something changed beach.jpg on disk after it was backed up. A run with
// --verify hashes every stored original, finds that one no longer matches
// the hash Ente recorded, says so, and downloads it again; it put back the
+80
View File
@@ -48,6 +48,78 @@ describe("run", () => {
);
});
// Node's `fetch` rejects every failure below HTTP with a TypeError that
// says only "fetch failed", and hangs the reason, with its errno, off
// `cause`. The printed line carries the reason, so a cron log tells a
// refused connection from a name that did not resolve.
it("prints the reason a fetch failed, from the error's cause", async () => {
const refused = new TypeError("fetch failed", {
cause: Object.assign(
new Error("connect ECONNREFUSED 1.2.3.4:443"),
{ code: "ECONNREFUSED" },
),
});
const result = await runToExit(Promise.reject(refused));
expect(result).toEqual({
code: 1,
stdout: "",
stderr: "quak: fetch failed: connect ECONNREFUSED 1.2.3.4:443\n",
});
});
// When the host has several addresses and every one refuses, Node's
// `fetch` gives as the cause an AggregateError with an empty message and
// one error per address. The printed line gives each address's reason.
it("prints the reason for each address a fetch could not reach", async () => {
const refusedAt = (address: string): Error =>
Object.assign(new Error(`connect ECONNREFUSED ${address}`), {
code: "ECONNREFUSED",
});
const refused = new TypeError("fetch failed", {
cause: Object.assign(
new AggregateError([
refusedAt("2001:db8::1:443"),
refusedAt("1.2.3.4:443"),
]),
{ code: "ECONNREFUSED" },
),
});
const result = await runToExit(Promise.reject(refused));
expect(result).toEqual({
code: 1,
stdout: "",
stderr: "quak: fetch failed: connect ECONNREFUSED 2001:db8::1:443, connect ECONNREFUSED 1.2.3.4:443\n",
});
});
// An error that already quotes its cause's message is printed as it is.
it("does not print a cause's message twice", async () => {
const reset = new Error("read ECONNRESET");
const wrapped = new Error(`download: file 7: ${reset.message}`, {
cause: reset,
});
const result = await runToExit(Promise.reject(wrapped));
expect(result.stderr).toBe("quak: download: file 7: read ECONNRESET\n");
});
// OpenSSL ends its message for a failed TLS connection, such as one to a
// server that answers in plain HTTP, with a newline. The printed line is
// still one line.
it("prints a cause's message without the white space around it", async () => {
const wrongVersion = new TypeError("fetch failed", {
cause: Object.assign(
new Error(
"C0B73A55A47F0000:error:0A00010B:SSL routines:ssl3_get_record:wrong version number:../deps/openssl/openssl/ssl/record/ssl3_record.c:354:\n",
),
{ code: "ERR_SSL_WRONG_VERSION_NUMBER" },
),
});
const result = await runToExit(Promise.reject(wrongVersion));
expect(result.stderr).toBe(
"quak: fetch failed: C0B73A55A47F0000:error:0A00010B:SSL routines:ssl3_get_record:wrong version number:../deps/openssl/openssl/ssl/record/ssl3_record.c:354:\n",
);
});
it("prints a thrown value that is not an Error", async () => {
const result = await runToExit(Promise.reject("offline"));
expect(result).toEqual({
@@ -57,6 +129,14 @@ describe("run", () => {
});
});
// A cause that is not an Error, such as a thrown string, is printed as a
// thrown value that is not an Error is.
it("prints a cause that is not an Error", async () => {
const wrapped = new Error("ML data", { cause: "offline" });
const result = await runToExit(Promise.reject(wrapped));
expect(result.stderr).toBe("quak: ML data: offline\n");
});
it("on a 401 from the server says to run quak login, on one line, and exits 3", async () => {
const result = await runToExit(
Promise.reject(new ApiError("unauthorized", 401)),
+10 -1
View File
@@ -74,7 +74,11 @@ import {
} from "vitest";
import { init, toBase64, STREAM_CHUNK_SIZE } from "../../src/crypto/index.js";
import { ApiClient } from "../../src/api/client.js";
import { ApiError, TruncatedStreamError } from "../../src/errors.js";
import {
ApiError,
describeError,
TruncatedStreamError,
} from "../../src/errors.js";
import type { RetryOptions } from "../../src/retry.js";
import {
downloadFile,
@@ -834,6 +838,11 @@ describe.each(entryPoints)(
expect(((err as Error).cause as Error).message).toMatch(
/authentication failed/i,
);
// As quak prints it and records it in `failures.json`, the error
// states the authentication failure once, from its cause.
expect(describeError(err)).toMatch(
/^download: stream truncated: response body ended with \d+ trailing bytes \(transfer stopped mid-chunk, or the data is corrupt\): secretstream chunk authentication failed$/,
);
// And, as with every other failure, the destination is untouched
// and no staged temp file survives.