-v raises the CLI's log level per repeat; -q prints only fatal errors (closes #212)
check / check (push) Canceled after 0s

Every line quak prints on stderr now goes through one log, in src/cli-log.ts, at one of six levels. The default, info, prints what quak printed before. -v adds each retry and the start and end of each refresh and ML data fetch; -vv adds each request with its result. -q prints only fatal lines, and is refused with -v; both are program options. Every non-zero exit logs one fatal line, so a run that exits 1 because files failed says how many and where to look. Every line reporting a failure is at error. --json changes stdout only, so backup and --json runs log their progress on stderr. withRetry and ApiClient report retries and requests through new callbacks.

Model: opus-5-5
This commit was merged in pull request #216.
This commit is contained in:
2026-10-18 21:46:59 +02:00
parent 92e0c6c791
commit 68d22b9927
21 changed files with 1099 additions and 188 deletions
+57 -12
View File
@@ -36,6 +36,15 @@ export interface ApiClientOptions {
retry?: RetryOptions;
requestTimeoutMs?: number;
downloadTimeoutMs?: number;
// Called once for each request sent, with the status of its response or
// the error it failed with, for a caller that logs requests. A request
// that is retried is reported once for each attempt.
onRequest?: (request: {
method: string;
url: string;
status?: number;
error?: unknown;
}) => void;
}
export interface StreamOptions {
@@ -159,6 +168,7 @@ export class ApiClient {
private readonly retry: ResolvedRetryOptions;
private readonly requestTimeoutMs: number;
private readonly downloadTimeoutMs: number;
private readonly onRequest: NonNullable<ApiClientOptions["onRequest"]>;
private token: string | undefined;
constructor(opts?: ApiClientOptions) {
@@ -180,6 +190,7 @@ export class ApiClient {
opts?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
this.downloadTimeoutMs =
opts?.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS;
this.onRequest = opts?.onRequest ?? (() => {});
this.token = opts?.authToken;
}
@@ -214,6 +225,33 @@ export class ApiClient {
return h;
}
// Send one request and report it to `onRequest`, with the status of its
// response or the error it failed with. `reportedURL` is the URL given to
// `onRequest`, when that must not be the one sent.
private async send(
url: string,
init: RequestInit & { method: string },
reportedURL = url,
): Promise<Response> {
let resp: Response;
try {
resp = await this._fetch(url, init);
} catch (err) {
this.onRequest({
method: init.method,
url: reportedURL,
error: err,
});
throw err;
}
this.onRequest({
method: init.method,
url: reportedURL,
status: resp.status,
});
return resp;
}
private async throwIfError(resp: Response): Promise<void> {
if (resp.ok) return;
const requestID = resp.headers.get("x-request-id") ?? undefined;
@@ -257,7 +295,7 @@ export class ApiClient {
const url = buildURL(this.apiOrigin, path, query);
// A GET changes nothing, so it is retried under the full policy.
return withRetry(async () => {
const resp = await this._fetch(url, {
const resp = await this.send(url, {
method: "GET",
headers: this.headers(),
signal: AbortSignal.timeout(this.requestTimeoutMs),
@@ -280,7 +318,7 @@ export class ApiClient {
// 3xx status, which is not retried.
return withRetry(
async () => {
const resp = await this._fetch(url, {
const resp = await this.send(url, {
method: "POST",
headers: this.headers({
"Content-Type": "application/json",
@@ -321,16 +359,23 @@ export class ApiClient {
// object at one key in one request, so replaying it either overwrites
// the same bytes or lands them for the first time. There is no partial
// state to protect, hence the full policy rather than the POST rule.
// It is reported without its query, which holds the upload's
// signature: a log keeps what it is given.
const { origin, pathname } = new URL(presignedURL);
await withRetry(async () => {
const resp = await this._fetch(presignedURL, {
method: "PUT",
headers: {
"Content-Type": "application/octet-stream",
"Content-Length": String(data.length),
const resp = await this.send(
presignedURL,
{
method: "PUT",
headers: {
"Content-Type": "application/octet-stream",
"Content-Length": String(data.length),
},
body: data,
signal: AbortSignal.timeout(this.requestTimeoutMs),
},
body: data,
signal: AbortSignal.timeout(this.requestTimeoutMs),
});
origin + pathname,
);
if (!resp.ok) {
// An ApiError, not a bare Error: without the status on the
// error the upload path cannot be classified at all, and a
@@ -348,7 +393,7 @@ export class ApiClient {
// Same replay and redirect rules as `postJSON`, for the same reasons.
return withRetry(
async () => {
const resp = await this._fetch(url, {
const resp = await this.send(url, {
method: "PUT",
headers: this.headers({
"Content-Type": "application/json",
@@ -394,7 +439,7 @@ export class ApiClient {
// headers, when no bytes have arrived either.
const deadline = idleDeadline(this.downloadTimeoutMs);
try {
const resp = await this._fetch(url, {
const resp = await this.send(url, {
method: "GET",
headers: this.headers(),
signal: deadline.signal,