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:
@@ -134,6 +134,13 @@ matters.
|
|||||||
| `2` | Another backup of the same directory is running. This run sent no request and changed nothing. |
|
| `2` | Another backup of the same directory is running. This run sent no request and changed nothing. |
|
||||||
| `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
|
||||||
|
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,
|
||||||
|
`quak: fetch failed: connect ECONNREFUSED 1.2.3.4:443`. A file whose download
|
||||||
|
failed that way is in `failures.json` with that error, as a transient 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
|
||||||
decrypt, or a file in `failures.json` because quak could not decrypt it, keeps
|
decrypt, or a file in `failures.json` because quak could not decrypt it, keeps
|
||||||
@@ -347,7 +354,8 @@ quak/
|
|||||||
exif.ts EXIF read from an image's bytes with exifreader
|
exif.ts EXIF read from an image's bytes with exifreader
|
||||||
mldata-fetch.ts fetch + decrypt per-file ML data
|
mldata-fetch.ts fetch + decrypt per-file ML data
|
||||||
filename.ts safe file names from server metadata
|
filename.ts safe file names from server metadata
|
||||||
errors.ts error types shared across layers
|
errors.ts error types shared across layers, and how an error
|
||||||
|
is described
|
||||||
retry.ts retry classifier + exponential backoff with jitter
|
retry.ts retry classifier + exponential backoff with jitter
|
||||||
thumbnails.ts detect + regenerate missing thumbnails
|
thumbnails.ts detect + regenerate missing thumbnails
|
||||||
client.ts high-level Client class assembled from the above
|
client.ts high-level Client class assembled from the above
|
||||||
|
|||||||
@@ -17,15 +17,28 @@ pre-1.0
|
|||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
None: no implementation work is open. The check of quak against a live Ente
|
The backup issues https://git.eeqj.de/sneak/quak/issues/197,
|
||||||
account, https://git.eeqj.de/sneak/quak/issues/187, needs an account only sneak
|
https://git.eeqj.de/sneak/quak/issues/198 and
|
||||||
has, so it waits on him.
|
https://git.eeqj.de/sneak/quak/issues/199, and the README issue
|
||||||
|
https://git.eeqj.de/sneak/quak/issues/200, are open. The check of quak against a
|
||||||
|
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.
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 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
|
||||||
|
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`. `run` prints that,
|
||||||
|
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
|
||||||
|
`hasTransportCode` in `src/retry.ts` recognises. The walk along the chain
|
||||||
|
moved from `src/retry.ts` to `src/errors.ts`, which both use. Tests refuse
|
||||||
|
every connection of a backup, and then only one file's download.
|
||||||
|
|
||||||
- 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
|
||||||
(issue 182). That many workers take files from one shared list, each doing all
|
(issue 182). That many workers take files from one shared list, each doing all
|
||||||
|
|||||||
+6
-3
@@ -82,6 +82,7 @@ import {
|
|||||||
init,
|
init,
|
||||||
} from "./crypto/index.js";
|
} from "./crypto/index.js";
|
||||||
import { removeLeftoverTempFiles } from "./download/index.js";
|
import { removeLeftoverTempFiles } from "./download/index.js";
|
||||||
|
import { describeError } from "./errors.js";
|
||||||
import { sanitizeFileName, withExtension } from "./filename.js";
|
import { sanitizeFileName, withExtension } from "./filename.js";
|
||||||
import {
|
import {
|
||||||
copyAtomic,
|
copyAtomic,
|
||||||
@@ -94,6 +95,7 @@ import type { Undecryptable } from "./library/store.js";
|
|||||||
import { extractImageMetadata } from "./metadata-backup.js";
|
import { extractImageMetadata } from "./metadata-backup.js";
|
||||||
import type { MLData } from "./mldata-fetch.js";
|
import type { MLData } from "./mldata-fetch.js";
|
||||||
import type { Collection, EnteFile, FileMetadata } from "./model/types.js";
|
import type { Collection, EnteFile, FileMetadata } from "./model/types.js";
|
||||||
|
import { hasTransportCode } from "./retry.js";
|
||||||
|
|
||||||
export type ProgressCallback = (message: string) => void;
|
export type ProgressCallback = (message: string) => void;
|
||||||
|
|
||||||
@@ -219,8 +221,10 @@ 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. Both the error code and message are inspected.
|
// anything else is unknown. The error code and message are inspected, and so
|
||||||
|
// is each errno on the error's cause chain, where Node's `fetch` puts it.
|
||||||
const classify = (err: unknown): FailureClass => {
|
const classify = (err: unknown): FailureClass => {
|
||||||
|
if (hasTransportCode(err)) return "transient";
|
||||||
const e = err as NodeJS.ErrnoException;
|
const e = err as NodeJS.ErrnoException;
|
||||||
const text =
|
const text =
|
||||||
`${e?.code ?? ""} ${err instanceof Error ? err.message : String(err)}`.toLowerCase();
|
`${e?.code ?? ""} ${err instanceof Error ? err.message : String(err)}`.toLowerCase();
|
||||||
@@ -241,8 +245,7 @@ const classify = (err: unknown): FailureClass => {
|
|||||||
return "unknown";
|
return "unknown";
|
||||||
};
|
};
|
||||||
|
|
||||||
const errorMessage = (err: unknown): string =>
|
const errorMessage = (err: unknown): string => describeError(err);
|
||||||
err instanceof Error ? err.message : String(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)
|
||||||
|
|||||||
+7
-6
@@ -2,14 +2,17 @@
|
|||||||
|
|
||||||
import type { Writable } from "node:stream";
|
import type { Writable } from "node:stream";
|
||||||
import { ApiError } from "./api/client.js";
|
import { ApiError } from "./api/client.js";
|
||||||
|
import { describeError } from "./errors.js";
|
||||||
|
|
||||||
// Run a command and exit with its code once stdout/stderr have drained.
|
// Run a command and exit with its code once stdout/stderr have drained.
|
||||||
// Exiting before the drain can truncate piped output, and the library can keep
|
// Exiting before the drain can truncate piped output, and the library can keep
|
||||||
// the event loop alive after a command returns, so a plain return could hang.
|
// the event loop alive after a command returns, so a plain return could hang.
|
||||||
// An error the command throws is printed as one `quak: MESSAGE` line, without
|
// An error the command throws is printed as one `quak: MESSAGE` line, without
|
||||||
// the stack trace, and exits 1. A 401 from the server means it no longer
|
// the stack trace, and exits 1. MESSAGE is the error as `describeError` gives
|
||||||
// accepts the saved session: that prints one line saying to log in again and
|
// it, so it carries the reason Node's `fetch` puts on `cause`. A 401 from the
|
||||||
// exits 3, as a missing or corrupt session file does.
|
// server means it no longer accepts the saved session: that prints one line
|
||||||
|
// saying to log in again and exits 3, as a missing or corrupt session file
|
||||||
|
// does.
|
||||||
export const run = async (
|
export const run = async (
|
||||||
command: Promise<number>,
|
command: Promise<number>,
|
||||||
stdout: Writable,
|
stdout: Writable,
|
||||||
@@ -26,9 +29,7 @@ export const run = async (
|
|||||||
);
|
);
|
||||||
code = 3;
|
code = 3;
|
||||||
} else {
|
} else {
|
||||||
stderr.write(
|
stderr.write(`quak: ${describeError(err)}\n`);
|
||||||
`quak: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
||||||
);
|
|
||||||
code = 1;
|
code = 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-3
@@ -1,10 +1,13 @@
|
|||||||
// Error types that more than one layer of quak needs to recognise.
|
// Error types that more than one layer of quak needs to recognise, and how an
|
||||||
|
// error is described in what quak prints and in `failures.json`.
|
||||||
//
|
//
|
||||||
// They live here rather than beside the code that throws them so that the
|
// The types live here rather than beside the code that throws them so that the
|
||||||
// retry classifier can identify them without importing the HTTP client or the
|
// retry classifier can identify them without importing the HTTP client or the
|
||||||
// download layer — both of which import the classifier. `ApiError` is
|
// download layer — both of which import the classifier. `ApiError` is
|
||||||
// re-exported from `src/api/client.ts`, which is where callers have always
|
// re-exported from `src/api/client.ts`, which is where callers have always
|
||||||
// imported it from and where it still belongs conceptually.
|
// imported it from and where it still belongs conceptually. The walk along an
|
||||||
|
// error's `cause` chain is here too, because the retry classifier reads the
|
||||||
|
// chain for errnos and `describeError` for messages.
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
readonly status: number;
|
readonly status: number;
|
||||||
@@ -43,3 +46,45 @@ export class TruncatedStreamError extends Error {
|
|||||||
this.name = "TruncatedStreamError";
|
this.name = "TruncatedStreamError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `cause` is an arbitrary user-settable property and nothing prevents it from
|
||||||
|
// forming a cycle, so the walk is bounded. Hanging the process would be worse
|
||||||
|
// than misclassifying an error or describing only part of it.
|
||||||
|
const MAX_CAUSE_DEPTH = 8;
|
||||||
|
|
||||||
|
// The error and each error on its `cause` chain, top first, at most
|
||||||
|
// `MAX_CAUSE_DEPTH` of them. `complete` is false when the walk stopped at the
|
||||||
|
// depth limit with more of the chain still below it.
|
||||||
|
export const causeChain = (
|
||||||
|
err: unknown,
|
||||||
|
): { chain: object[]; complete: boolean } => {
|
||||||
|
const chain: object[] = [];
|
||||||
|
let current: unknown = err;
|
||||||
|
for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth++) {
|
||||||
|
if (current === null || typeof current !== "object") {
|
||||||
|
return { chain, complete: true };
|
||||||
|
}
|
||||||
|
chain.push(current);
|
||||||
|
const { cause } = current as { cause?: unknown };
|
||||||
|
if (cause === current) return { chain, complete: true };
|
||||||
|
current = cause;
|
||||||
|
}
|
||||||
|
return { chain, complete: current === null || typeof current !== "object" };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// `fetch` rejects with just "fetch failed" and puts the reason on `cause`, so
|
||||||
|
// this gives "fetch failed: connect ECONNREFUSED 1.2.3.4:443". A cause whose
|
||||||
|
// message is already in the text before it 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 } = cause as { message?: unknown };
|
||||||
|
if (typeof message === "string" && !text.includes(message)) {
|
||||||
|
text += `: ${message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|||||||
+12
-16
@@ -10,7 +10,7 @@
|
|||||||
// declining to retry a transient one costs a single file that the next run
|
// declining to retry a transient one costs a single file that the next run
|
||||||
// picks up anyway. When the evidence is ambiguous, fail fast.
|
// picks up anyway. When the evidence is ambiguous, fail fast.
|
||||||
|
|
||||||
import { ApiError, TruncatedStreamError } from "./errors.js";
|
import { ApiError, causeChain, TruncatedStreamError } from "./errors.js";
|
||||||
|
|
||||||
export interface RetryOptions {
|
export interface RetryOptions {
|
||||||
// Total calls, not retries: `attempts: 1` disables retrying.
|
// Total calls, not retries: `attempts: 1` disables retrying.
|
||||||
@@ -89,11 +89,6 @@ const TRANSPORT_CODES = new Set([
|
|||||||
// narrowed. See `isSafeToReplay`.
|
// narrowed. See `isSafeToReplay`.
|
||||||
const CONNECT_CODES = new Set(["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED"]);
|
const CONNECT_CODES = new Set(["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED"]);
|
||||||
|
|
||||||
// `cause` is an arbitrary user-settable property and nothing prevents it from
|
|
||||||
// forming a cycle, so the walk is bounded. Hanging the process would be a
|
|
||||||
// worse outcome than any misclassification.
|
|
||||||
const MAX_CAUSE_DEPTH = 8;
|
|
||||||
|
|
||||||
// Collect every `code` in an error's cause chain. undici does not put the
|
// Collect every `code` in an error's cause chain. undici does not put the
|
||||||
// errno on the error it throws — it hangs the underlying socket error off
|
// errno on the error it throws — it hangs the underlying socket error off
|
||||||
// `cause`, sometimes more than one level down — so a classifier that only read
|
// `cause`, sometimes more than one level down — so a classifier that only read
|
||||||
@@ -101,20 +96,21 @@ const MAX_CAUSE_DEPTH = 8;
|
|||||||
// connection permanent. `complete` is false when the walk stopped at the
|
// connection permanent. `complete` is false when the walk stopped at the
|
||||||
// depth limit with more of the chain still below it.
|
// depth limit with more of the chain still below it.
|
||||||
const causeCodes = (err: unknown): { codes: string[]; complete: boolean } => {
|
const causeCodes = (err: unknown): { codes: string[]; complete: boolean } => {
|
||||||
|
const { chain, complete } = causeChain(err);
|
||||||
const codes: string[] = [];
|
const codes: string[] = [];
|
||||||
let current: unknown = err;
|
for (const link of chain) {
|
||||||
for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth++) {
|
const { code } = link as { code?: unknown };
|
||||||
if (current === null || typeof current !== "object") {
|
|
||||||
return { codes, complete: true };
|
|
||||||
}
|
|
||||||
const { code, cause } = current as { code?: unknown; cause?: unknown };
|
|
||||||
if (typeof code === "string") codes.push(code);
|
if (typeof code === "string") codes.push(code);
|
||||||
if (cause === current) return { codes, complete: true };
|
|
||||||
current = cause;
|
|
||||||
}
|
}
|
||||||
return { codes, complete: current === null || typeof current !== "object" };
|
return { codes, complete };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Does the error's cause chain hold the errno of a transport failure, one of
|
||||||
|
// `TRANSPORT_CODES`? `isRetryable` retries such an error, and `classify` in
|
||||||
|
// `src/backup.ts` calls it transient.
|
||||||
|
export const hasTransportCode = (err: unknown): boolean =>
|
||||||
|
causeCodes(err).codes.some((code) => TRANSPORT_CODES.has(code));
|
||||||
|
|
||||||
const isAbort = (err: unknown): boolean => {
|
const isAbort = (err: unknown): boolean => {
|
||||||
if (err === null || typeof err !== "object") return false;
|
if (err === null || typeof err !== "object") return false;
|
||||||
const { name } = err as { name?: unknown };
|
const { name } = err as { name?: unknown };
|
||||||
@@ -158,7 +154,7 @@ export const isRetryable = (err: unknown): boolean => {
|
|||||||
// have succeeded; the cost of the imprecision is bounded by the attempt
|
// have succeeded; the cost of the imprecision is bounded by the attempt
|
||||||
// count.
|
// count.
|
||||||
if (err instanceof TypeError) return true;
|
if (err instanceof TypeError) return true;
|
||||||
return causeCodes(err).codes.some((code) => TRANSPORT_CODES.has(code));
|
return hasTransportCode(err);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Could the first attempt already have taken effect on the server?
|
// Could the first attempt already have taken effect on the server?
|
||||||
|
|||||||
@@ -329,6 +329,16 @@ const downloadOf = (input: RequestInfo | URL): number | undefined => {
|
|||||||
: 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
|
// The fake server, pushing onto `downloads` the ID of each file whose original
|
||||||
// is downloaded.
|
// is downloaded.
|
||||||
const recordingDownloads =
|
const recordingDownloads =
|
||||||
@@ -619,6 +629,67 @@ describe("quak backup", () => {
|
|||||||
expect(readdirSync(dir)).toEqual([]);
|
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
|
// 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
|
// --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
|
// 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 () => {
|
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({
|
||||||
|
|||||||
Reference in New Issue
Block a user