// 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; };