Compare commits

..
1 Commits
Author SHA1 Message Date
sneak e93bb06330 A network failure is printed and recorded with its reason (closes #196)
check / check (push) Canceled after 0s
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
2026-10-15 23:13:31 +00:00
8 changed files with 80 additions and 217 deletions
+5 -16
View File
@@ -19,10 +19,6 @@ encrypting and uploading them back to the server.
## Getting Started ## Getting Started
The commands below need git, node 22 or later, and yarn. `make install`, which
builds the single binary that "Running the backup from cron" uses, also needs
[nix](https://nixos.org/download).
```bash ```bash
git clone https://git.eeqj.de/sneak/quak.git git clone https://git.eeqj.de/sneak/quak.git
cd quak cd quak
@@ -90,12 +86,9 @@ enumeration/download calls) is exported too and documented under Design below.
## Running the backup from cron ## Running the backup from cron
`quak backup` never prompts, so cron can run it. `make install` builds quak as a `quak backup` never prompts, so cron can run it. `make install` builds quak as a
single binary and copies it to `~/bin/quak`. Besides what Getting Started needs, single binary and copies it to `~/bin/quak`. Log in once with it, as the user
it needs [nix](https://nixos.org/download), even where bun is already installed: the cron job will run as; the session is saved in that user's data directory
`make install` runs `make build-bin`, which bundles the built `dist/` with bun (see "Session handling"):
run through `nix-shell -p bun`. Log in once with `~/bin/quak`, as the user the
cron job will run as; the session is saved in that user's data directory (see
"Session handling"):
```bash ```bash
~/bin/quak login ~/bin/quak login
@@ -145,12 +138,8 @@ matters.
caused it. Node's `fetch` reports every failure below HTTP as just caused it. Node's `fetch` reports every failure below HTTP as just
`fetch failed` and gives the reason as its cause, so a run that could not reach `fetch failed` and gives the reason as its cause, so a run that could not reach
the server prints, for example, the server prints, for example,
`quak: fetch failed: connect ECONNREFUSED 1.2.3.4:443`. When the server has `quak: fetch failed: connect ECONNREFUSED 1.2.3.4:443`. A file whose download
several addresses and none of them could be reached, the line gives the reason failed that way is in `failures.json` with that error, as a transient failure.
for each, separated by `, `. A file whose download failed that way is in
`failures.json` with that error, as a transient failure. So is each file with no
ML data cached when the ML data fetch failed that way, its error starting
`ML data: `.
After a `1`, the next run fetches each missing original again, fetches the ML After a `1`, the next run fetches each missing original again, fetches the ML
data that is not cached, and rebuilds the album links. An album quak could not data that is not cached, and rebuilds the album links. An album quak could not
+9 -14
View File
@@ -17,11 +17,12 @@ pre-1.0
# Next Step # Next Step
Three bugs are open: https://git.eeqj.de/sneak/quak/issues/197, The backup issues https://git.eeqj.de/sneak/quak/issues/197,
https://git.eeqj.de/sneak/quak/issues/198 and https://git.eeqj.de/sneak/quak/issues/198 and
https://git.eeqj.de/sneak/quak/issues/199. The check of quak against a live Ente https://git.eeqj.de/sneak/quak/issues/199, and the README issue
account, https://git.eeqj.de/sneak/quak/issues/187, needs an account only sneak https://git.eeqj.de/sneak/quak/issues/200, are open. The check of quak against a
has, so it waits on him. live Ente account, https://git.eeqj.de/sneak/quak/issues/187, needs an account
only sneak has, so it waits on him.
Tagging and releases are decided by sneak alone, and happen only when he Tagging and releases are decided by sneak alone, and happen only when he
declares one. declares one.
@@ -31,18 +32,12 @@ declares one.
- 2026-10-15: A network failure is printed and recorded with its reason, not as - 2026-10-15: A network failure is printed and recorded with its reason, not as
just `fetch failed` (issue 196). `describeError` in `src/errors.ts` gives an just `fetch failed` (issue 196). `describeError` in `src/errors.ts` gives an
error's message followed by the message of each error on its `cause` chain, error's message followed by the message of each error on its `cause` chain,
such as `fetch failed: connect ECONNREFUSED 1.2.3.4:443`, and each address's such as `fetch failed: connect ECONNREFUSED 1.2.3.4:443`. `run` prints that,
reason when a host has several and none could be reached. `run` prints that,
and the backup logs it and records it in `failures.json`, where `classify` now and the backup logs it and records it in `failures.json`, where `classify` now
calls the failure transient when the chain holds an errno that calls the failure transient when the chain holds an errno that
`hasTransportCode` in `src/retry.ts` recognises, for a failed ML data fetch `hasTransportCode` in `src/retry.ts` recognises. The walk along the chain
too. The walk along the chain moved from `src/retry.ts` to `src/errors.ts`, moved from `src/retry.ts` to `src/errors.ts`, which both use. Tests refuse
which both use. Tests refuse every connection of a backup, then only one every connection of a backup, and then only one file's download.
file's download, and fail the ML data fetch.
- 2026-10-15: The README says that `make install` needs nix (issue 200), in
Getting Started and in "Running the backup from cron": `make build-bin` runs
bun through `nix-shell -p bun`. The `Makefile` is unchanged.
- 2026-10-14: `lib.backup()`, and so `quak backup`, works on up to the content - 2026-10-14: `lib.backup()`, and so `quak backup`, works on up to the content
pool's limit of originals at once, 5 by default, instead of one at a time pool's limit of originals at once, 5 by default, instead of one at a time
+5 -7
View File
@@ -221,13 +221,13 @@ const isPresent = (path: string): boolean => {
// Best-effort classification for the ledger. Retryable server/network problems // Best-effort classification for the ledger. Retryable server/network problems
// are transient; refusals and local filesystem/decrypt errors are permanent; // are transient; refusals and local filesystem/decrypt errors are permanent;
// anything else is unknown. The error code and the error as `describeError` // anything else is unknown. The error code and message are inspected, and so
// gives it are inspected, and so is each errno on the error's cause chain, // is each errno on the error's cause chain, where Node's `fetch` puts it.
// where Node's `fetch` puts it.
const classify = (err: unknown): FailureClass => { const classify = (err: unknown): FailureClass => {
if (hasTransportCode(err)) return "transient"; if (hasTransportCode(err)) return "transient";
const e = err as NodeJS.ErrnoException; const e = err as NodeJS.ErrnoException;
const text = `${e?.code ?? ""} ${describeError(err)}`.toLowerCase(); const text =
`${e?.code ?? ""} ${err instanceof Error ? err.message : String(err)}`.toLowerCase();
if ( if (
/timeout|timed out|econnreset|econnrefused|econnaborted|network|socket|eai_again|throttl|temporarily|429|500|502|503|504/.test( /timeout|timed out|econnreset|econnrefused|econnaborted|network|socket|eai_again|throttl|temporarily|429|500|502|503|504/.test(
text, text,
@@ -788,13 +788,11 @@ const runLockedBackup = async (
// instead and is recorded as failed. The next run fetches its ML data // instead and is recorded as failed. The next run fetches its ML data
// again because none is cached. // again because none is cached.
if (includeOriginals) { if (includeOriginals) {
let mlDataFailure: unknown;
let mlDataError: string | undefined; let mlDataError: string | undefined;
try { try {
log("Fetching ML data..."); log("Fetching ML data...");
await lib.fetchMLData(); await lib.fetchMLData();
} catch (err) { } catch (err) {
mlDataFailure = err;
mlDataError = errorMessage(err); mlDataError = errorMessage(err);
log(`FAILED ML data: ${mlDataError}`); log(`FAILED ML data: ${mlDataError}`);
} }
@@ -819,7 +817,7 @@ const runLockedBackup = async (
file.id, file.id,
file.metadata.title, file.metadata.title,
collectionName.get(file.collectionID) ?? "", collectionName.get(file.collectionID) ?? "",
new Error("ML data", { cause: mlDataFailure }), new Error(`ML data: ${mlDataError}`),
); );
} else { } else {
writeSidecar(path, file, { mlData }, image); writeSidecar(path, file, { mlData }, image);
+3 -5
View File
@@ -135,17 +135,15 @@ const streamDecrypt = async (
// cannot tell a partial chunk from a corrupt one, so this // cannot tell a partial chunk from a corrupt one, so this
// is reported as the truncation it almost always is, with // is reported as the truncation it almost always is, with
// the authentication failure kept as the error's cause. // the authentication failure kept as the error's cause.
// The message leaves that failure to the cause, which // Only the pull is guarded: a sink failure on a chunk
// `describeError` appends when the error is printed or // that did authenticate is a disk error, not a
// recorded. Only the pull is guarded: a sink failure on a
// chunk that did authenticate is a disk error, not a
// truncation. // truncation.
let pulled; let pulled;
try { try {
pulled = pullStreamChunk(state, buffer); pulled = pullStreamChunk(state, buffer);
} catch (err) { } catch (err) {
throw new TruncatedStreamError( throw new TruncatedStreamError(
`download: stream truncated: response body ended with ${buffer.length} trailing bytes (transfer stopped mid-chunk, or the data is corrupt)`, `download: stream truncated: response body ended with ${buffer.length} trailing bytes that did not authenticate as a final chunk (transfer stopped mid-chunk, or the data is corrupt)`,
{ cause: err }, { cause: err },
); );
} }
+3 -13
View File
@@ -75,23 +75,13 @@ export const causeChain = (
// An error as quak prints it and records it in `failures.json`: its message, // An error as quak prints it and records it in `failures.json`: its message,
// then the message of each error on its `cause` chain, joined with ": ". Node's // then the message of each error on its `cause` chain, joined with ": ". Node's
// `fetch` rejects with just "fetch failed" and puts the reason on `cause`, so // `fetch` rejects with just "fetch failed" and puts the reason on `cause`, so
// this gives "fetch failed: connect ECONNREFUSED 1.2.3.4:443". When the host // this gives "fetch failed: connect ECONNREFUSED 1.2.3.4:443". A cause whose
// has several addresses and every one fails, the cause is an `AggregateError` // message is already in the text before it is left out.
// with an empty message and one error per address in `errors`, so an
// `AggregateError` is given as the messages of its errors, joined with ", ".
// A cause whose message is already in the text before it is left out.
export const describeError = (err: unknown): string => { export const describeError = (err: unknown): string => {
if (!(err instanceof Error)) return String(err); if (!(err instanceof Error)) return String(err);
let text = err.message; let text = err.message;
for (const cause of causeChain(err).chain.slice(1)) { for (const cause of causeChain(err).chain.slice(1)) {
const message = const { message } = cause as { message?: unknown };
cause instanceof AggregateError
? cause.errors
.map((e: unknown) =>
e instanceof Error ? e.message : String(e),
)
.join(", ")
: (cause as { message?: unknown }).message;
if (typeof message === "string" && !text.includes(message)) { if (typeof message === "string" && !text.includes(message)) {
text += `: ${message}`; text += `: ${message}`;
} }
+54 -127
View File
@@ -329,41 +329,16 @@ const downloadOf = (input: RequestInfo | URL): number | undefined => {
: undefined; : undefined;
}; };
// The error Node gives for a connection to `address` that was refused. // What Node's `fetch` rejects with when the connection is refused, as for
const refusedAt = (address: string): Error => // every failure below HTTP: a TypeError that says only "fetch failed", with
Object.assign(new Error(`connect ECONNREFUSED ${address}`), { // the reason, and its errno, as its cause.
code: "ECONNREFUSED", const refused = (): TypeError =>
new TypeError("fetch failed", {
cause: Object.assign(new Error("connect ECONNREFUSED 1.2.3.4:443"), {
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 // The fake server, pushing onto `downloads` the ID of each file whose original
// is downloaded. // is downloaded.
const recordingDownloads = const recordingDownloads =
@@ -654,90 +629,38 @@ describe("quak backup", () => {
expect(readdirSync(dir)).toEqual([]); expect(readdirSync(dir)).toEqual([]);
}); });
// The server refuses every connection, at its one address or at each of // The server refuses every connection. quak tries each request again,
// several. quak tries each request again, here without waiting, and then // here without waiting, and then the run stops at the refresh it starts
// the run stops at the refresh it starts with. The line it prints gives // with. The line it prints gives the reason Node's `fetch` put on the
// the reason Node's `fetch` put on the error's cause, so a cron log tells // error's cause, so a cron log tells a refused connection from a name
// a refused connection from a name that did not resolve, and the exit // that did not resolve, and the exit code is 1.
// code is 1. it("prints why the refresh could not reach the server, and exits 1", async () => {
it.each(REFUSED)( const dir = mkdtempSync(join(root, "backup-"));
"prints why the refresh could not reach the server at $addresses, and exits 1", const fetch: typeof globalThis.fetch = async () => {
async ({ error, reason }) => { throw refused();
const dir = mkdtempSync(join(root, "backup-")); };
const fetch: typeof globalThis.fetch = async () => {
throw error();
};
const { code, stderr } = await backup(dir, {}, fetch); const { code, stderr } = await backup(dir, {}, fetch);
expect(code).toBe(1); expect(code).toBe(1);
expect(stderr).toBe( expect(stderr).toBe(
"Starting backup...\n" + "Starting backup...\n" +
"Refreshing library...\n" + "Refreshing library...\n" +
`quak: ${reason}\n`, "quak: fetch failed: connect ECONNREFUSED 1.2.3.4:443\n",
); );
}, });
);
// The files CDN refuses every connection for toast.mp4, at its one // The files CDN refuses every connection for toast.mp4. Once its retries
// address or at each of several. Once its retries are spent, the file // are spent, the file goes into `failures.json` with the reason, as a
// goes into `failures.json` with the reason, as a transient failure, // transient failure, since a later run may well reach the CDN, and the
// since a later run may well reach the CDN, and the lines the backup // lines the backup prints for it give the same reason. The rest of the
// prints for it give the same reason. The rest of the account is backed // account is backed up, and the exit code is 1.
// 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 () => {
it.each(REFUSED)( let attempts = 0;
"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) => { const fetch: typeof globalThis.fetch = async (input, init) => {
if (new URL(String(input)).pathname === "/files/data/fetch") { if (downloadOf(input) === 200) {
throw new TypeError("fetch failed", { attempts++;
cause: Object.assign( throw refused();
new Error("getaddrinfo ENOTFOUND api.ente.io"),
{ code: "ENOTFOUND" },
),
});
} }
return server(input, init); return server(input, init);
}; };
@@ -746,21 +669,25 @@ describe("quak backup", () => {
const { code, stderr } = await backup(dir, {}, fetch); const { code, stderr } = await backup(dir, {}, fetch);
expect(code).toBe(1); expect(code).toBe(1);
const error = expect(attempts).toBe(UNATTENDED_RETRY_OPTIONS.attempts);
"ML data: fetch failed: getaddrinfo ENOTFOUND api.ente.io"; const error = "fetch failed: connect ECONNREFUSED 1.2.3.4:443";
expect(stderr).toContain(`FAILED ${error}\n`);
const failures = readJSON(join(dir, "failures.json")); const failures = readJSON(join(dir, "failures.json"));
expect( expect(Object.keys(failures.files)).toEqual(["200"]);
Object.values(failures.files).map((f) => [ expect(failures.files["200"]).toMatchObject({
f.fileID, fileID: 200,
f.classification, title: "toast.mp4",
f.error, classification: "transient",
]), attempts: 1,
).toEqual([ error,
[100, "transient", error], });
[200, "transient", error], expect(stderr).toContain(`FAILED original toast.mp4: ${error}\n`);
[300, "transient", error], 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 // Something changed beach.jpg on disk after it was backed up. A run with
-25
View File
@@ -67,31 +67,6 @@ describe("run", () => {
}); });
}); });
// 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. // An error that already quotes its cause's message is printed as it is.
it("does not print a cause's message twice", async () => { it("does not print a cause's message twice", async () => {
const reset = new Error("read ECONNRESET"); const reset = new Error("read ECONNRESET");
+1 -10
View File
@@ -74,11 +74,7 @@ import {
} from "vitest"; } from "vitest";
import { init, toBase64, STREAM_CHUNK_SIZE } from "../../src/crypto/index.js"; import { init, toBase64, STREAM_CHUNK_SIZE } from "../../src/crypto/index.js";
import { ApiClient } from "../../src/api/client.js"; import { ApiClient } from "../../src/api/client.js";
import { import { ApiError, TruncatedStreamError } from "../../src/errors.js";
ApiError,
describeError,
TruncatedStreamError,
} from "../../src/errors.js";
import type { RetryOptions } from "../../src/retry.js"; import type { RetryOptions } from "../../src/retry.js";
import { import {
downloadFile, downloadFile,
@@ -838,11 +834,6 @@ describe.each(entryPoints)(
expect(((err as Error).cause as Error).message).toMatch( expect(((err as Error).cause as Error).message).toMatch(
/authentication failed/i, /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, as with every other failure, the destination is untouched
// and no staged temp file survives. // and no staged temp file survives.