A network failure is printed and recorded with its reason (closes #196)
check / check (push) Waiting to run
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:
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user