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
5 changes: 5 additions & 0 deletions .changeset/improve-error-handling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@alchemy/cli": patch
---

Improve error messages for unknown commands, invalid networks, RPC failures, and access denied responses so users get actionable guidance instead of raw errors.
3 changes: 3 additions & 0 deletions src/commands/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ Examples:
"block must be a number, hex, or 'latest'",
);
}
if (num < 0) {
throw errInvalidArgs("Block number must be non-negative.");
}
blockParam = `0x${num.toString(16)}`;
}

Expand Down
16 changes: 14 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Command, Help } from "commander";
import { EXIT_CODES, errSetupRequired, exitWithError } from "./lib/errors.js";
import { EXIT_CODES, errInvalidArgs, errSetupRequired, exitWithError } from "./lib/errors.js";
import { setFlags, isJSONMode, quiet } from "./lib/output.js";
import { formatCommanderError } from "./lib/error-format.js";
import { load as loadConfig } from "./lib/config.js";
Expand Down Expand Up @@ -155,6 +155,7 @@ program
.option("--debug", "Enable debug diagnostics")
.option("--no-interactive", "Disable REPL and prompt-driven interactions")
.addHelpCommand(false)
.allowExcessArguments(true)
.exitOverride((err) => {
if (
err.code === "commander.help" ||
Expand Down Expand Up @@ -335,7 +336,18 @@ program
}
resetUpdateNoticeState();
})
.action(async () => {
.action(async (_opts: unknown, cmd: Command) => {
// Commander routes here when no subcommand matches. If the user passed
// positional args (e.g. "alchemy abcd"), those are unknown commands.
const excessArgs = cmd.args;
if (excessArgs.length > 0) {
exitWithError(
errInvalidArgs(
`Unknown command '${excessArgs[0]}'. Run 'alchemy help' for available commands.`,
),
);
}

const cfg = loadConfig();
if (!isSetupComplete(cfg) && !isInteractiveAllowed(program)) {
throw errSetupRequired(getSetupStatus(cfg));
Expand Down
15 changes: 14 additions & 1 deletion src/lib/admin-client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
errInvalidAccessKey,
errAccessDenied,
errNotFound,
errRateLimited,
errAdminAPI,
Expand Down Expand Up @@ -128,7 +129,19 @@ export class AdminClient {
...(body !== undefined && { body: JSON.stringify(body) }),
});

if (resp.status === 401 || resp.status === 403) throw errInvalidAccessKey();
if (resp.status === 401) throw errInvalidAccessKey();
if (resp.status === 403) {
const detail = await resp.text().catch(() => "");
// Try to extract a reason from the response body
let reason: string | undefined;
try {
const parsed = JSON.parse(detail);
reason = parsed?.message || parsed?.error?.message || parsed?.error || undefined;
} catch {
reason = detail || undefined;
}
throw errAccessDenied(typeof reason === "string" ? reason : undefined);
}
if (resp.status === 404) {
const text = await resp.text().catch(() => "");
throw errNotFound(text || path);
Expand Down
24 changes: 22 additions & 2 deletions src/lib/client-utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { errInvalidArgs, errNetwork } from "./errors.js";
import { CLIError, errInvalidArgs, errNetwork } from "./errors.js";
import { timeout as globalTimeout } from "./output.js";

export function isLocalhost(hostname: string): boolean {
Expand Down Expand Up @@ -50,6 +50,26 @@ export async function fetchWithTimeout(
if (err instanceof DOMException && err.name === "TimeoutError") {
throw errNetwork(`Request timed out after ${globalTimeout}ms`);
}
throw errNetwork((err as Error).message);
const message = (err as Error).message ?? String(err);
// Node's fetch wraps DNS errors in a TypeError with the detail in .cause
const causeMessage = (err as { cause?: { message?: string } }).cause?.message ?? "";
const causeCode = (err as { cause?: { code?: string } }).cause?.code ?? "";
const fullErrorText = `${message} ${causeMessage} ${causeCode}`;
// Detect DNS resolution failures — typically caused by an invalid network slug
if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(fullErrorText)) {
// Extract the hostname from the URL for a clearer error message
try {
const hostname = new URL(url).hostname;
const networkSlug = hostname.replace(/\.g\.alchemy\.com$/, "");
if (networkSlug !== hostname) {
throw errInvalidArgs(
`Unknown network '${networkSlug}'. Run 'alchemy network list' to see available networks.`,
);
}
} catch (innerErr) {
if (innerErr instanceof CLIError) throw innerErr;
}
}
throw errNetwork(message);
}
}
25 changes: 23 additions & 2 deletions src/lib/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,14 @@ export class Client implements AlchemyClient {
try {
parsed = new URL(`https://${hostname}`);
} catch {
throw errInvalidArgs(`Invalid network: ${network}`);
throw errInvalidArgs(
`Unknown network '${network}'. Run 'alchemy network list' to see available networks.`,
);
}
if (!parsed.hostname.endsWith(".g.alchemy.com")) {
throw errInvalidArgs(`Invalid network: ${network} — hostname must end with .g.alchemy.com`);
throw errInvalidArgs(
`Unknown network '${network}'. Run 'alchemy network list' to see available networks.`,
);
}
}

Expand Down Expand Up @@ -87,6 +91,18 @@ export class Client implements AlchemyClient {
return errInvalidAPIKey(detail || undefined);
}

private tryParseRPCError(text: string): CLIError | null {
try {
const parsed = JSON.parse(text);
if (parsed?.error?.code !== undefined && parsed?.error?.message !== undefined) {
return errRPC(parsed.error.code, parsed.error.message);
}
} catch {
// Not JSON — fall through
}
return null;
}

private async doFetch(url: string, init: RequestInit): Promise<Response> {
return fetchWithTimeout(url, init);
}
Expand Down Expand Up @@ -116,6 +132,9 @@ export class Client implements AlchemyClient {

if (!resp.ok) {
const text = await resp.text().catch(() => "");
// Try to parse as JSON-RPC error before falling back to network error
const rpcError = this.tryParseRPCError(text);
if (rpcError) throw rpcError;
throw errNetwork(`HTTP ${resp.status}: ${text}`);
}

Expand Down Expand Up @@ -149,6 +168,8 @@ export class Client implements AlchemyClient {

if (!resp.ok) {
const text = await resp.text().catch(() => "");
const rpcError = this.tryParseRPCError(text);
if (rpcError) throw rpcError;
throw errNetwork(`HTTP ${resp.status}: ${text}`);
}

Expand Down
22 changes: 21 additions & 1 deletion src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,17 @@ export function errNetwork(detail: string): CLIError {
);
}

const RPC_ERROR_HINTS: Record<number, string> = {
[-32700]: "Parse error. The request JSON is malformed.",
[-32600]: "Invalid request. Check the JSON-RPC request format.",
[-32601]: "Method not supported. Check the method name and ensure your plan supports it.",
[-32602]: "Invalid parameters. Check argument types and format.",
[-32603]: "Internal JSON-RPC error.",
};

export function errRPC(code: number, message: string): CLIError {
return new CLIError(ErrorCode.RPC_ERROR, `RPC error ${code}: ${message}`);
const hint = RPC_ERROR_HINTS[code];
return new CLIError(ErrorCode.RPC_ERROR, `RPC error ${code}: ${message}`, hint);
}

export function errInvalidArgs(detail: string): CLIError {
Expand All @@ -158,6 +167,17 @@ export function errInvalidAccessKey(): CLIError {
);
}

export function errAccessDenied(detail?: string): CLIError {
const message = detail
? `Access denied: ${detail}`
: "Access denied. Your access key may not have permission for this operation.";
return new CLIError(
ErrorCode.INVALID_ACCESS_KEY,
message,
"Check your account tier and feature access at https://dashboard.alchemy.com/",
);
}

export function errAppRequired(): CLIError {
return new CLIError(
ErrorCode.APP_REQUIRED,
Expand Down
34 changes: 34 additions & 0 deletions tests/commands/block.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,40 @@ describe("block command", () => {
expect(exitWithError).toHaveBeenCalledTimes(1);
});

it("block -1 rejects negative block numbers", async () => {
const exitWithError = vi.fn();
vi.doMock("../../src/lib/resolve.js", () => ({
clientFromFlags: vi.fn(),
}));
vi.doMock("../../src/lib/output.js", () => ({
isJSONMode: () => true,
printJSON: vi.fn(),
verbose: false,
}));
vi.doMock("../../src/lib/ui.js", () => ({
bold: (s: string) => s,
dim: (s: string) => s,
withSpinner: vi.fn(),
printKeyValueBox: vi.fn(),
printSyntaxJSON: vi.fn(),
}));
vi.doMock("../../src/lib/block-format.js", () => ({
formatBlockTimestamp: vi.fn(),
formatHexQuantity: vi.fn(),
formatGasSummary: vi.fn(),
}));
vi.doMock("../../src/lib/errors.js", async () => ({ ...(await vi.importActual("../../src/lib/errors.js")), exitWithError }));

const { registerBlock } = await import("../../src/commands/block.js");
const program = new Command();
registerBlock(program);

await program.parseAsync(["node", "test", "block", "-1"], { from: "node" });
expect(exitWithError).toHaveBeenCalledTimes(1);
const err = exitWithError.mock.calls[0][0];
expect(err.message).toContain("non-negative");
});

it("block latest prints JSON payload in json mode", async () => {
const call = vi.fn().mockResolvedValue({ number: "0x10", hash: "0xhash" });
const printJSON = vi.fn();
Expand Down
20 changes: 19 additions & 1 deletion tests/lib/admin-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ describe("AdminClient", () => {
}
});

it("throws INVALID_ACCESS_KEY on 403", async () => {
it("throws INVALID_ACCESS_KEY on 403 with access denied message", async () => {
const url = await createTestServer((_req, res) => {
res.writeHead(403);
res.end();
Expand All @@ -348,6 +348,24 @@ describe("AdminClient", () => {
} catch (err) {
expect(err).toBeInstanceOf(CLIError);
expect((err as CLIError).code).toBe(ErrorCode.INVALID_ACCESS_KEY);
expect((err as CLIError).message).toContain("Access denied");
}
});

it("includes reason from 403 response body", async () => {
const url = await createTestServer((_req, res) => {
res.writeHead(403, { "Content-Type": "application/json" });
res.end(JSON.stringify({ message: "Feature not available on your current plan" }));
});

const client = new TestAdminClient(url);
try {
await client.listApps();
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(CLIError);
expect((err as CLIError).code).toBe(ErrorCode.INVALID_ACCESS_KEY);
expect((err as CLIError).message).toContain("Feature not available");
}
});

Expand Down
40 changes: 40 additions & 0 deletions tests/lib/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,46 @@ describe("Client.call", () => {
}
});

it("throws RPC error when HTTP error body contains JSON-RPC error", async () => {
const url = await createTestServer((_req, res) => {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
jsonrpc: "2.0",
error: { code: -32601, message: "The method fake_method does not exist" },
id: 1,
}),
);
});

const client = new TestClient(url);
try {
await client.call("fake_method");
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(CLIError);
expect((err as CLIError).code).toBe(ErrorCode.RPC_ERROR);
expect((err as CLIError).message).toContain("-32601");
expect((err as CLIError).hint).toBeDefined();
}
});

it("throws network error when HTTP error body is not JSON-RPC", async () => {
const url = await createTestServer((_req, res) => {
res.writeHead(500);
res.end("Internal Server Error");
});

const client = new TestClient(url);
try {
await client.call("eth_blockNumber");
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(CLIError);
expect((err as CLIError).code).toBe(ErrorCode.NETWORK_ERROR);
}
});

it("throws on 429", async () => {
const url = await createTestServer((_req, res) => {
res.writeHead(429);
Expand Down
18 changes: 18 additions & 0 deletions tests/lib/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,24 @@ describe("convenience constructors", () => {
}
});

describe("errRPC hints", () => {
it("includes hint for known RPC error codes", () => {
const err = errRPC(-32601, "Method not found");
expect(err.hint).toBeDefined();
expect(err.hint).toContain("Method not supported");
});

it("includes hint for invalid params", () => {
const err = errRPC(-32602, "Invalid params");
expect(err.hint).toContain("Invalid parameters");
});

it("has no hint for unknown RPC error codes", () => {
const err = errRPC(-32000, "Execution reverted");
expect(err.hint).toBeUndefined();
});
});

describe("EXIT_CODES", () => {
it("has a mapping for every ErrorCode", () => {
for (const code of Object.values(ErrorCode)) {
Expand Down
Loading