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
114 lines
4.9 KiB
TypeScript
114 lines
4.9 KiB
TypeScript
// 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`.
|
|
//
|
|
// 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
|
|
// download layer — both of which import the classifier. `ApiError` is
|
|
// re-exported from `src/api/client.ts`, which is where callers have always
|
|
// 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 {
|
|
readonly status: number;
|
|
readonly code?: string;
|
|
readonly requestID?: string;
|
|
readonly body?: unknown;
|
|
constructor(
|
|
message: string,
|
|
status: number,
|
|
opts?: { code?: string; requestID?: string; body?: unknown },
|
|
) {
|
|
super(message);
|
|
this.name = "ApiError";
|
|
this.status = status;
|
|
this.code = opts?.code;
|
|
this.requestID = opts?.requestID;
|
|
this.body = opts?.body;
|
|
}
|
|
}
|
|
|
|
// A response body that ended before the secretstream did.
|
|
//
|
|
// This is a type rather than a message prefix because it is a decision, not a
|
|
// diagnostic: the retry policy asks "was this a short transfer?" and acts on
|
|
// the answer. Matching on the wording of an error message would make the next
|
|
// person to reword a diagnostic silently turn every truncated download into a
|
|
// permanent failure, and the failure would look like a corrupt file rather
|
|
// than like a bug.
|
|
//
|
|
// `cause` carries the underlying authentication failure on the one path where
|
|
// there is one — a body that stopped part-way through a chunk, which Poly1305
|
|
// cannot distinguish from corruption.
|
|
export class TruncatedStreamError extends Error {
|
|
constructor(message: string, opts?: ErrorOptions) {
|
|
super(message, opts);
|
|
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". When the host
|
|
// 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 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);
|
|
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)).trim(),
|
|
)
|
|
.join(", ")
|
|
: (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;
|
|
};
|