Compare commits

..
1 Commits
Author SHA1 Message Date
sneak 0be9ec7b32 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
2026-10-16 00:31:35 +00:00
5 changed files with 67 additions and 25 deletions
+5 -4
View File
@@ -142,15 +142,16 @@ 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. |
`<message>` is the error's message, followed by the message of each error that
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
the server prints, for example,
caused it. Under Node, as `yarn quak` runs quak, `fetch` reports every failure
below HTTP as just `fetch failed` and gives the reason as its cause, so a run
that could not reach the server prints, for example,
`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
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: `.
`ML data: `. The `~/bin/quak` that `make install` builds runs under bun, and
prints bun's own wording for such a failure.
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
+12 -10
View File
@@ -245,8 +245,6 @@ const classify = (err: unknown): FailureClass => {
return "unknown";
};
const errorMessage = (err: unknown): string => describeError(err);
// 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)
// so the caller records it and moves on rather than aborting the run.
@@ -473,7 +471,7 @@ const imageMetadataFor = async (
const bytes = await readFile(originalPath);
return { imageMetadata: extractImageMetadata(bytes) ?? {} };
} catch (err) {
return { imageMetadataError: errorMessage(err) };
return { imageMetadataError: describeError(err) };
}
};
@@ -613,7 +611,7 @@ const runLockedBackup = async (
// and thumbnail both fail this run must not double its attempt count
// or appear twice in errors.
if (failedThisRun.has(fileID)) return;
const error = errorMessage(err);
const error = describeError(err);
errors.push({ fileID, title, collection, error });
const prior = ledger.get(fileID);
ledger.set(fileID, {
@@ -684,7 +682,7 @@ const runLockedBackup = async (
}
} catch (err) {
log(
`FAILED verifying original ${file.metadata.title}: ${errorMessage(err)}`,
`FAILED verifying original ${file.metadata.title}: ${describeError(err)}`,
);
recordFailure(
file.id,
@@ -720,7 +718,9 @@ const runLockedBackup = async (
}
downloaded++;
} catch (err) {
log(`FAILED original ${file.metadata.title}: ${errorMessage(err)}`);
log(
`FAILED original ${file.metadata.title}: ${describeError(err)}`,
);
recordFailure(
file.id,
file.metadata.title,
@@ -795,7 +795,7 @@ const runLockedBackup = async (
await lib.fetchMLData();
} catch (err) {
mlDataFailure = err;
mlDataError = errorMessage(err);
mlDataError = describeError(err);
log(`FAILED ML data: ${mlDataError}`);
}
for (const file of distinct.values()) {
@@ -851,7 +851,7 @@ const runLockedBackup = async (
downloadDirectory,
);
} catch (err) {
log(`FAILED removing old album directories: ${errorMessage(err)}`);
log(`FAILED removing old album directories: ${describeError(err)}`);
}
for (const c of collections) {
@@ -873,7 +873,9 @@ const runLockedBackup = async (
try {
removeStaleLinks(colDir, new Set(linkNames), downloadDirectory);
} catch (err) {
log(`FAILED removing old links in ${c.name}: ${errorMessage(err)}`);
log(
`FAILED removing old links in ${c.name}: ${describeError(err)}`,
);
}
const metaFiles = files.map((f) => ({
@@ -890,7 +892,7 @@ const runLockedBackup = async (
);
} catch (err) {
log(
`FAILED symlink ${c.name}/${linkName}: ${errorMessage(err)}`,
`FAILED symlink ${c.name}/${linkName}: ${describeError(err)}`,
);
recordFailure(
link.file.id,
+2 -2
View File
@@ -136,8 +136,8 @@ const streamDecrypt = async (
// is reported as the truncation it almost always is, with
// the authentication failure kept as the error's cause.
// The message leaves that failure to the cause, which
// `describeError` appends when the error is printed or
// recorded. Only the pull is guarded: a sink failure on a
// `describeError` appends to what `quak backup` prints and
// records. Only the pull is guarded: a sink failure on a
// chunk that did authenticate is a disk error, not a
// truncation.
let pulled;
+22 -9
View File
@@ -79,22 +79,35 @@ export const causeChain = (
// 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
// `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.
// A cause that is not an object, such as a thrown string, is given as
// `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 => {
if (!(err instanceof Error)) return String(err);
let text = err.message;
for (const cause of causeChain(err).chain.slice(1)) {
const message =
const { chain } = causeChain(err);
const messages = chain
.slice(1)
.map((cause) =>
cause instanceof AggregateError
? cause.errors
.map((e: unknown) =>
e instanceof Error ? e.message : String(e),
(e instanceof Error ? e.message : String(e)).trim(),
)
.join(", ")
: (cause as { message?: unknown }).message;
if (typeof message === "string" && !text.includes(message)) {
text += `: ${message}`;
}
: (cause as { message?: unknown }).message,
);
// `causeChain` stops at a cause that is not an object, so that one is read
// 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;
};
+26
View File
@@ -102,6 +102,24 @@ describe("run", () => {
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({
@@ -111,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)),