Compare commits

..
1 Commits
Author SHA1 Message Date
sneak c2cd64cb40 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`. `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
2026-10-15 23:50:38 +00:00
5 changed files with 25 additions and 67 deletions
+4 -5
View File
@@ -142,16 +142,15 @@ matters.
| `3` | There is no usable session: none is saved, the saved one is corrupt, or the server no longer accepts it. Run `quak login` as the user the cron job runs as. | | `3` | There is no usable session: none is saved, the saved one is corrupt, or the server no longer accepts it. Run `quak login` as the user the cron job runs as. |
`<message>` is the error's message, followed by the message of each error that `<message>` is the error's message, followed by the message of each error that
caused it. Under Node, as `yarn quak` runs quak, `fetch` reports every failure caused it. Node's `fetch` reports every failure below HTTP as just
below HTTP as just `fetch failed` and gives the reason as its cause, so a run `fetch failed` and gives the reason as its cause, so a run that could not reach
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`. When the server has
several addresses and none of them could be reached, the line gives the reason several addresses and none of them could be reached, the line gives the reason
for each, separated by `, `. A file whose download failed that way is in 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 `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 cached when the ML data fetch failed that way, its error starting
`ML data: `. The `~/bin/quak` that `make install` builds runs under bun, and `ML data: `.
prints bun's own wording for such a failure.
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
+10 -12
View File
@@ -245,6 +245,8 @@ const classify = (err: unknown): FailureClass => {
return "unknown"; return "unknown";
}; };
const errorMessage = (err: unknown): string => describeError(err);
// Ensure `linkPath` is a symlink to `target`, rebuilding a missing, wrong, or // Ensure `linkPath` is a symlink to `target`, rebuilding a missing, wrong, or
// non-symlink entry. Throws on failure (a directory in the way, no permission) // non-symlink entry. Throws on failure (a directory in the way, no permission)
// so the caller records it and moves on rather than aborting the run. // so the caller records it and moves on rather than aborting the run.
@@ -471,7 +473,7 @@ const imageMetadataFor = async (
const bytes = await readFile(originalPath); const bytes = await readFile(originalPath);
return { imageMetadata: extractImageMetadata(bytes) ?? {} }; return { imageMetadata: extractImageMetadata(bytes) ?? {} };
} catch (err) { } catch (err) {
return { imageMetadataError: describeError(err) }; return { imageMetadataError: errorMessage(err) };
} }
}; };
@@ -611,7 +613,7 @@ const runLockedBackup = async (
// and thumbnail both fail this run must not double its attempt count // and thumbnail both fail this run must not double its attempt count
// or appear twice in errors. // or appear twice in errors.
if (failedThisRun.has(fileID)) return; if (failedThisRun.has(fileID)) return;
const error = describeError(err); const error = errorMessage(err);
errors.push({ fileID, title, collection, error }); errors.push({ fileID, title, collection, error });
const prior = ledger.get(fileID); const prior = ledger.get(fileID);
ledger.set(fileID, { ledger.set(fileID, {
@@ -682,7 +684,7 @@ const runLockedBackup = async (
} }
} catch (err) { } catch (err) {
log( log(
`FAILED verifying original ${file.metadata.title}: ${describeError(err)}`, `FAILED verifying original ${file.metadata.title}: ${errorMessage(err)}`,
); );
recordFailure( recordFailure(
file.id, file.id,
@@ -718,9 +720,7 @@ const runLockedBackup = async (
} }
downloaded++; downloaded++;
} catch (err) { } catch (err) {
log( log(`FAILED original ${file.metadata.title}: ${errorMessage(err)}`);
`FAILED original ${file.metadata.title}: ${describeError(err)}`,
);
recordFailure( recordFailure(
file.id, file.id,
file.metadata.title, file.metadata.title,
@@ -795,7 +795,7 @@ const runLockedBackup = async (
await lib.fetchMLData(); await lib.fetchMLData();
} catch (err) { } catch (err) {
mlDataFailure = err; mlDataFailure = err;
mlDataError = describeError(err); mlDataError = errorMessage(err);
log(`FAILED ML data: ${mlDataError}`); log(`FAILED ML data: ${mlDataError}`);
} }
for (const file of distinct.values()) { for (const file of distinct.values()) {
@@ -851,7 +851,7 @@ const runLockedBackup = async (
downloadDirectory, downloadDirectory,
); );
} catch (err) { } catch (err) {
log(`FAILED removing old album directories: ${describeError(err)}`); log(`FAILED removing old album directories: ${errorMessage(err)}`);
} }
for (const c of collections) { for (const c of collections) {
@@ -873,9 +873,7 @@ const runLockedBackup = async (
try { try {
removeStaleLinks(colDir, new Set(linkNames), downloadDirectory); removeStaleLinks(colDir, new Set(linkNames), downloadDirectory);
} catch (err) { } catch (err) {
log( log(`FAILED removing old links in ${c.name}: ${errorMessage(err)}`);
`FAILED removing old links in ${c.name}: ${describeError(err)}`,
);
} }
const metaFiles = files.map((f) => ({ const metaFiles = files.map((f) => ({
@@ -892,7 +890,7 @@ const runLockedBackup = async (
); );
} catch (err) { } catch (err) {
log( log(
`FAILED symlink ${c.name}/${linkName}: ${describeError(err)}`, `FAILED symlink ${c.name}/${linkName}: ${errorMessage(err)}`,
); );
recordFailure( recordFailure(
link.file.id, link.file.id,
+2 -2
View File
@@ -136,8 +136,8 @@ const streamDecrypt = async (
// 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 // The message leaves that failure to the cause, which
// `describeError` appends to what `quak backup` prints and // `describeError` appends when the error is printed or
// records. Only the pull is guarded: a sink failure on a // recorded. Only the pull is guarded: a sink failure on a
// chunk that did authenticate is a disk error, not a // chunk that did authenticate is a disk error, not a
// truncation. // truncation.
let pulled; let pulled;
+9 -22
View File
@@ -79,35 +79,22 @@ export const causeChain = (
// has several addresses and every one fails, the cause is an `AggregateError` // has several addresses and every one fails, the cause is an `AggregateError`
// with an empty message and one error per address in `errors`, so an // with an empty message and one error per address in `errors`, so an
// `AggregateError` is given as the messages of its errors, joined with ", ". // `AggregateError` is given as the messages of its errors, joined with ", ".
// A cause that is not an object, such as a thrown string, is given as // A cause whose message is already in the text before it is left out.
// `String(cause)`. Each message is trimmed of the white space around it, as
// OpenSSL ends its own with a newline. A message the text already holds 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);
const { chain } = causeChain(err); let text = err.message;
const messages = chain for (const cause of causeChain(err).chain.slice(1)) {
.slice(1) const message =
.map((cause) =>
cause instanceof AggregateError cause instanceof AggregateError
? cause.errors ? cause.errors
.map((e: unknown) => .map((e: unknown) =>
(e instanceof Error ? e.message : String(e)).trim(), e instanceof Error ? e.message : String(e),
) )
.join(", ") .join(", ")
: (cause as { message?: unknown }).message, : (cause as { message?: unknown }).message;
); if (typeof message === "string" && !text.includes(message)) {
// `causeChain` stops at a cause that is not an object, so that one is read text += `: ${message}`;
// from the last error on the chain. }
const last = chain[chain.length - 1] as { cause?: unknown };
if (last.cause !== undefined && typeof last.cause !== "object") {
messages.push(String(last.cause));
}
let text = err.message.trim();
for (const message of messages) {
if (typeof message !== "string") continue;
const trimmed = message.trim();
if (!text.includes(trimmed)) text += `: ${trimmed}`;
} }
return text; return text;
}; };
-26
View File
@@ -102,24 +102,6 @@ describe("run", () => {
expect(result.stderr).toBe("quak: download: file 7: read ECONNRESET\n"); 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 () => { it("prints a thrown value that is not an Error", async () => {
const result = await runToExit(Promise.reject("offline")); const result = await runToExit(Promise.reject("offline"));
expect(result).toEqual({ expect(result).toEqual({
@@ -129,14 +111,6 @@ 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 () => { it("on a 401 from the server says to run quak login, on one line, and exits 3", async () => {
const result = await runToExit( const result = await runToExit(
Promise.reject(new ApiError("unauthorized", 401)), Promise.reject(new ApiError("unauthorized", 401)),