Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Updated

- Improvements to error reporting including for fetch failures.

## 0.6.1 - 2026-07-28

### Updated
Expand Down
46 changes: 41 additions & 5 deletions src/commandHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,7 @@ export function handleError(
}
} else {
if (error instanceof HttpError) {
process.stderr.write(
pc.red(
`The API returned an error with status code ${error.status}:\n\n${JSON.stringify(error.body, null, 2)}\n`,
),
);
process.stderr.write(pc.red(formatHttpError(error)));
} else if (
error instanceof CommanderError &&
(error.code === "commander.help" || error.message === "(outputHelp)")
Expand All @@ -96,6 +92,46 @@ export function handleError(
process.exit(exitCode);
}

/**
* `status` and `body` are only populated when the API actually answered.
* Network, TLS, and non-JSON failures carry their detail in `message` alone, so
* these helpers coalesce so we display full details.
*/
function formatHttpError(error: HttpError): string {
const heading = formatHttpErrorHeading(error);
const body = formatHttpErrorBody(error.body);
return body === null ? `${heading}\n` : `${heading}\n\n${body}\n`;
}

function formatHttpErrorHeading(error: HttpError): string {
if (error.status === undefined) {
return error.message;
}

// A successful or redirect status that still failed the request — an
// unparseable body, or a redirect missing its Location — isn't an error the
// API reported, so don't describe it as one.
if (error.status < 400) {
return `${error.message} (HTTP ${error.status})`;
}

return `The API returned an error with status code ${error.status}: ${error.message}`;
}

function formatHttpErrorBody(body: unknown): string | null {
if (body === undefined || body === "") {
return null;
}

// Non-JSON bodies (proxy block pages, HTML error pages) are far more legible
// raw than as a JSON string literal full of escaped newlines.
if (typeof body === "string") {
return body;
}

return JSON.stringify(body, null, 2) ?? String(body);
}

function printJson(value: Record<string, unknown>): void {
process.stdout.write(JSON.stringify(value, null, 2) + "\n");
}
Expand Down
6 changes: 4 additions & 2 deletions src/commands/studio/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
wrapAction,
} from "../../commandHelpers.js";
import { CliError, EXIT_CODES, HttpError } from "../../errors.js";
import { request } from "../../http.js";
import { describeFetchFailure, request } from "../../http.js";
import {
AsyncProgressReporter,
renderJson,
Expand Down Expand Up @@ -351,7 +351,9 @@ async function fetchResponse(
try {
response = await fetch(url, init);
} catch (error) {
throw new HttpError(`Request failed: ${(error as Error).message}`);
throw new HttpError(
`Could not reach ${url}: ${describeFetchFailure(error)}`,
);
}

return response;
Expand Down
79 changes: 78 additions & 1 deletion src/http.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { parseRetryAfterMs, request } from "./http.js";
import { describeFetchFailure, parseRetryAfterMs, request } from "./http.js";
import { HttpError } from "./errors.js";
import { createLogger } from "./logger.js";
import type { Runtime } from "./types.js";

Expand Down Expand Up @@ -88,6 +89,82 @@ describe("request", () => {
});
});

describe("describeFetchFailure", () => {
it("surfaces the cause behind Node's generic 'fetch failed'", () => {
const cause = Object.assign(
new Error("unable to verify the first certificate"),
{ code: "UNABLE_TO_VERIFY_LEAF_SIGNATURE" },
);

expect(describeFetchFailure(new TypeError("fetch failed", { cause }))).toBe(
"unable to verify the first certificate (UNABLE_TO_VERIFY_LEAF_SIGNATURE)",
);
});

it("keeps the top-level message when there is no cause", () => {
expect(
describeFetchFailure(
new TypeError("Failed to parse URL from api.example.com/auth.info"),
),
).toBe("Failed to parse URL from api.example.com/auth.info");
expect(describeFetchFailure(new TypeError("fetch failed"))).toBe(
"fetch failed",
);
});

it("handles non-Error rejections", () => {
expect(describeFetchFailure("boom")).toBe("boom");
});
});

describe("request error details", () => {
it("includes the fetch cause in the thrown message", async () => {
const cause = Object.assign(new Error("getaddrinfo ENOTFOUND"), {
code: "ENOTFOUND",
});
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(new TypeError("fetch failed", { cause })),
);

await expect(request(runtime, "/auth.info")).rejects.toThrow(
"Could not reach https://api.example.com/auth.info: getaddrinfo ENOTFOUND (ENOTFOUND)",
);
});

it("attaches the status and raw body when a 2xx response is not JSON", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValue(
new Response("<html>Blocked by proxy</html>", { status: 200 }),
),
);

const error = await request(runtime, "/auth.info").catch((e) => e);

expect(error).toBeInstanceOf(HttpError);
expect((error as HttpError).status).toBe(200);
expect((error as HttpError).body).toBe("<html>Blocked by proxy</html>");
expect((error as HttpError).message).toContain(
"Invalid JSON response from https://api.example.com/auth.info",
);
});

it("reports an empty 2xx body distinctly", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(new Response("", { status: 200 })),
);

const error = await request(runtime, "/auth.info").catch((e) => e);

expect((error as HttpError).status).toBe(200);
expect((error as HttpError).message).toContain("Empty response body");
});
});

describe("http logging", () => {
it("logs request and response details with redacted authorization", async () => {
const writes: string[] = [];
Expand Down
46 changes: 44 additions & 2 deletions src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ export async function request<T extends Record<string, unknown>>(
body: requestBody,
});
} catch (error) {
throw new HttpError(`Request failed: ${(error as Error).message}`);
throw new HttpError(
`Could not reach ${url}: ${describeFetchFailure(error)}`,
);
}

const responseBodyText = await response.text();
Expand All @@ -88,14 +90,54 @@ export async function request<T extends Record<string, unknown>>(
typeof parsedResponseBody === "string" &&
parsedResponseBody === responseBodyText
) {
throw new HttpError("Invalid JSON response: Unexpected token");
// A 2xx that isn't JSON usually means something other than the API answered
// e.g. a proxy block page, a captive portal, etc. Pass the status
// and the raw text along so the user can see what actually came back.
throw new HttpError(
responseBodyText
? `Invalid JSON response from ${url}`
: `Empty response body from ${url}`,
response.status,
responseBodyText,
);
}

const retryAfterMs = parseRetryAfterMs(response.headers);
const body = parsedResponseBody as T;
return retryAfterMs === undefined ? { body } : { body, retryAfterMs };
}

/**
* Flatten an error thrown by `fetch` into a single readable message.
*
* Node reports every DNS, proxy, and TLS failure as `TypeError: fetch failed`
* and hangs the real reason off `cause`, so the top-level message alone can't
* distinguish an untrusted corporate root certificate from an unreachable host.
*/
export function describeFetchFailure(error: unknown): string {
if (!(error instanceof Error)) {
return String(error);
}

const parts: string[] = [];
let current: unknown = error;
while (current instanceof Error) {
const { code } = current as NodeJS.ErrnoException;
const part = code ? `${current.message} (${code})` : current.message;
if (!parts.includes(part)) {
parts.push(part);
}
current = current.cause;
}
Comment on lines +122 to +131

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥🔥🔥


// "fetch failed" carries no information once a cause is available.
const informative = parts.filter(
(part) => parts.length === 1 || part !== "fetch failed",
);

return informative.join(": ");
}

export function parseRetryAfterMs(headers: Headers): number | undefined {
const retryAfter = headers.get("retry-after");
if (!retryAfter) {
Expand Down
Loading