Skip to content
Open
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/api-error-formatting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": patch
---

Improve how API error responses are displayed: when a response contains multiple errors they are now all shown instead of just the first, and bodies that carry a plain `error` or `message` field are surfaced directly rather than as raw JSON.
61 changes: 30 additions & 31 deletions packages/cli-core/src/cli-program.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { test, expect, describe } from "bun:test";
import { createProgram, formatApiBody } from "./cli-program.ts";
import { ApiError } from "./lib/errors.ts";

test("registers users as a top-level command", () => {
const program = createProgram();
Expand Down Expand Up @@ -160,7 +159,7 @@ describe("formatApiBody", () => {
},
],
});
const result = formatApiBody(new ApiError(400, body), false);
const result = formatApiBody(body, false);
expect(result).toContain("Your plan does not support these features");
expect(result).toContain("Unsupported features: saml, custom_roles");
});
Expand All @@ -175,7 +174,7 @@ describe("formatApiBody", () => {
},
],
});
const result = formatApiBody(new ApiError(400, body), false);
const result = formatApiBody(body, false);
expect(result).toContain("Unknown config key: sesion");
expect(result).toContain("Did you mean: session");
expect(result).toContain("Parameter: sesion");
Expand All @@ -191,7 +190,7 @@ describe("formatApiBody", () => {
},
],
});
const result = formatApiBody(new ApiError(400, body), false);
const result = formatApiBody(body, false);
expect(result).toContain("This feature is not enabled on this instance");
expect(result).toContain("Feature: organizations");
});
Expand All @@ -206,7 +205,7 @@ describe("formatApiBody", () => {
},
],
});
const result = formatApiBody(new ApiError(400, body), false);
const result = formatApiBody(body, false);
expect(result).toContain("Invalid value for session.lifetime");
expect(result).toContain("Parameter: session.lifetime");
});
Expand All @@ -221,7 +220,7 @@ describe("formatApiBody", () => {
},
],
});
const result = formatApiBody(new ApiError(400, body), false);
const result = formatApiBody(body, false);
expect(result).toContain("Cannot clear this key");
expect(result).toContain("Parameter: sign_up.mode");
});
Expand All @@ -236,15 +235,14 @@ describe("formatApiBody", () => {
},
],
});
const result = formatApiBody(new ApiError(400, body), false);
const result = formatApiBody(body, false);
expect(result).toContain("Value is not in the allowed set");
expect(result).toContain("Parameter: branding.logo_url");
});

// --- Multiple errors ---
// The structured path reads from the first parsed error only.

test("formats multiple errors: surfaces first error with its meta", () => {
test("formats multiple errors joined by newlines", () => {
const body = JSON.stringify({
errors: [
{
Expand All @@ -259,9 +257,13 @@ describe("formatApiBody", () => {
},
],
});
const result = formatApiBody(new ApiError(400, body), false);
const result = formatApiBody(body, false);
expect(result).toContain("Invalid session lifetime");
expect(result).toContain("Parameter: session.lifetime");
expect(result).toContain("Unknown key: bogus");
expect(result).toContain("Did you mean: session");
// Two errors separated by newline
const lines = result.split("\n");
expect(lines.length).toBeGreaterThanOrEqual(2);
});

// --- Error without meta ---
Expand All @@ -270,34 +272,32 @@ describe("formatApiBody", () => {
const body = JSON.stringify({
errors: [{ code: "resource_not_found", message: "Instance not found" }],
});
const result = formatApiBody(new ApiError(400, body), false);
const result = formatApiBody(body, false);
expect(result).toBe("Instance not found");
});

// --- Bodies without a Clerk errors array ---
// parseApiBody falls back to truncateBody(body) as the message when there
// is no errors[0], so formatStructuredError returns the truncated body string.
// --- Fallback paths ---

test("returns truncated body when no errors array (error field only)", () => {
test("falls back to parsed.error when no errors array", () => {
const body = JSON.stringify({ error: "Something went wrong" });
const result = formatApiBody(new ApiError(400, body), false);
expect(result).toBe(body);
const result = formatApiBody(body, false);
expect(result).toBe("Something went wrong");
});

test("returns truncated body when no errors array (message field only)", () => {
test("falls back to parsed.message when no errors array or error field", () => {
const body = JSON.stringify({ message: "Bad request" });
const result = formatApiBody(new ApiError(400, body), false);
expect(result).toBe(body);
const result = formatApiBody(body, false);
expect(result).toBe("Bad request");
});

test("truncates non-JSON body over 200 chars", () => {
const body = "x".repeat(300);
const result = formatApiBody(new ApiError(400, body), false);
const result = formatApiBody(body, false);
expect(result).toBe("x".repeat(200) + "...");
});

test("returns short non-JSON body as-is", () => {
const result = formatApiBody(new ApiError(400, "Bad Request"), false);
const result = formatApiBody("Bad Request", false);
expect(result).toBe("Bad Request");
});

Expand All @@ -306,29 +306,28 @@ describe("formatApiBody", () => {
test("verbose mode returns full pretty-printed JSON", () => {
const obj = { errors: [{ code: "test", message: "test msg" }] };
const body = JSON.stringify(obj);
const result = formatApiBody(new ApiError(400, body), true);
const result = formatApiBody(body, true);
expect(result).toBe("\n" + JSON.stringify(obj, null, 2));
});

test("verbose mode returns raw body for non-JSON", () => {
const result = formatApiBody(new ApiError(400, "not json"), true);
const result = formatApiBody("not json", true);
expect(result).toBe("\nnot json");
});

// --- Edge cases ---

test("handles empty errors array by returning truncated body", () => {
test("handles empty errors array by falling through to message", () => {
const body = JSON.stringify({ errors: [], message: "fallback" });
const result = formatApiBody(new ApiError(400, body), false);
// No errors[0] so parseApiBody falls back to truncateBody(body)
expect(result).toBe(body);
const result = formatApiBody(body, false);
expect(result).toBe("fallback");
});

test("handles error with empty meta", () => {
const body = JSON.stringify({
errors: [{ code: "config_validation_error", message: "Bad value", meta: {} }],
});
const result = formatApiBody(new ApiError(400, body), false);
const result = formatApiBody(body, false);
expect(result).toBe("Bad value");
});

Expand All @@ -342,7 +341,7 @@ describe("formatApiBody", () => {
},
],
});
const result = formatApiBody(new ApiError(400, body), false);
const result = formatApiBody(body, false);
expect(result).toBe("Plan limitation");
});
});
35 changes: 26 additions & 9 deletions packages/cli-core/src/cli-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,23 +146,40 @@ export function createProgram(): Program {
return program;
}

export function formatApiBody(error: ApiError, verbose: boolean): string {
export function formatApiBody(body: string, verbose: boolean): string {
if (verbose) {
try {
return "\n" + JSON.stringify(JSON.parse(error.body), null, 2);
return "\n" + JSON.stringify(JSON.parse(body), null, 2);
} catch {
return "\n" + error.body;
return "\n" + body;
}
}
return formatStructuredError(error);

try {
const parsed = JSON.parse(body);
if (Array.isArray(parsed.errors) && parsed.errors.length > 0) {
return parsed.errors.map(formatSingleError).join("\n");
}
if (parsed.error) return parsed.error;
if (parsed.message) return parsed.message;
Comment on lines +163 to +164

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard fallback fields to strings only.

Line 163 and Line 164 return parsed values without type checks. If error/message is non-string JSON, CLI output degrades to [object Object] instead of a useful fallback.

Suggested fix
-    if (parsed.error) return parsed.error;
-    if (parsed.message) return parsed.message;
+    if (typeof parsed.error === "string") return parsed.error;
+    if (typeof parsed.message === "string") return parsed.message;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (parsed.error) return parsed.error;
if (parsed.message) return parsed.message;
if (typeof parsed.error === "string") return parsed.error;
if (typeof parsed.message === "string") return parsed.message;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli-core/src/cli-program.ts` around lines 163 - 164, The code at
lines 163-164 returns parsed.error and parsed.message without verifying they are
strings, causing non-string values to display as [object Object] in CLI output.
Add type guards using typeof checks to ensure both parsed.error and
parsed.message are strings before returning them. If either field exists but is
not a string type, skip that return statement and continue to the next fallback
check or provide an appropriate string representation.

} catch {
// not JSON
}

if (body.length > 200) return body.slice(0, 200) + "...";
return body;
}

function formatStructuredError(error: ApiError): string {
let msg = error.message;
const { meta, code } = error;
function formatSingleError(err: {
message?: string;
code?: string;
meta?: Record<string, unknown>;
}): string {
let msg = err.message ?? "Unknown error";
const meta = err.meta;
if (!meta) return msg;

switch (code) {
switch (err.code) {
case "unsupported_subscription_plan_features": {
const features = meta.unsupported_features;
if (Array.isArray(features) && features.length > 0) {
Expand Down Expand Up @@ -247,7 +264,7 @@ export async function runProgram(
}

if (error instanceof ApiError) {
const detail = formatApiBody(error, verbose);
const detail = formatApiBody(error.body, verbose);
const prefix = error.context ?? "Request failed";
if (isAgent()) {
const apiErrors: ApiErrorEntry[] | undefined =
Expand Down