Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2cd64cb40 |
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
Reference in New Issue
Block a user