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`, so quak printed and recorded only that, and
`failures.json` called the failure `unknown`. `describeError` in
`src/errors.ts` gives the message followed by the messages on the cause
chain; `run` prints it, and the backup logs and records it. `classify`
calls a failure transient when `hasTransportCode`, now exported by
`src/retry.ts`, finds a transport errno on the chain. The bounded walk
along the chain moved from `src/retry.ts` to `src/errors.ts`, so both
use one walk.

Every message the backup logs or records, `imageMetadataError` and
`mlDataError` included, now carries its causes too.

Model: opus-5-5
This commit is contained in:
2026-10-15 23:13:31 +00:00
parent 3412e42af5
commit e93bb06330
8 changed files with 198 additions and 32 deletions
+71
View File
@@ -329,6 +329,16 @@ const downloadOf = (input: RequestInfo | URL): number | undefined => {
: undefined;
};
// What Node's `fetch` rejects with when the connection is refused, as for
// every failure below HTTP: a TypeError that says only "fetch failed", with
// the reason, and its errno, as its cause.
const refused = (): TypeError =>
new TypeError("fetch failed", {
cause: Object.assign(new Error("connect ECONNREFUSED 1.2.3.4:443"), {
code: "ECONNREFUSED",
}),
});
// The fake server, pushing onto `downloads` the ID of each file whose original
// is downloaded.
const recordingDownloads =
@@ -619,6 +629,67 @@ describe("quak backup", () => {
expect(readdirSync(dir)).toEqual([]);
});
// The server refuses every connection. 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("prints why the refresh could not reach the server, and exits 1", async () => {
const dir = mkdtempSync(join(root, "backup-"));
const fetch: typeof globalThis.fetch = async () => {
throw refused();
};
const { code, stderr } = await backup(dir, {}, fetch);
expect(code).toBe(1);
expect(stderr).toBe(
"Starting backup...\n" +
"Refreshing library...\n" +
"quak: fetch failed: connect ECONNREFUSED 1.2.3.4:443\n",
);
});
// The files CDN refuses every connection for toast.mp4. 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("records why a download could not reach the server in failures.json, as transient, and exits 1", async () => {
let attempts = 0;
const fetch: typeof globalThis.fetch = async (input, init) => {
if (downloadOf(input) === 200) {
attempts++;
throw refused();
}
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 error = "fetch failed: connect ECONNREFUSED 1.2.3.4:443";
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,
});
expect(stderr).toContain(`FAILED original toast.mp4: ${error}\n`);
expect(stderr).toContain(
`[Friend's Wedding] toast.mp4 (id 200): ${error}\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);
});
// 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
+29
View File
@@ -48,6 +48,35 @@ 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",
});
});
// 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");
});
it("prints a thrown value that is not an Error", async () => {
const result = await runToExit(Promise.reject("offline"));
expect(result).toEqual({