From b2eb2c8a74deb15d007d7cb5db0fa7f2e11e03ac Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 17 Jun 2026 14:24:07 -0600 Subject: [PATCH] fix(cli): show all API errors and surface plain error/message bodies Parse the raw response body in formatApiBody so multi-error responses are all shown (joined by newlines) instead of only the first error, and bodies that carry a plain error or message field are surfaced directly rather than as raw JSON. The error classes keep their structured fields unchanged. --- .changeset/api-error-formatting.md | 5 ++ packages/cli-core/src/cli-program.test.ts | 61 +++++++++++------------ packages/cli-core/src/cli-program.ts | 35 +++++++++---- 3 files changed, 61 insertions(+), 40 deletions(-) create mode 100644 .changeset/api-error-formatting.md diff --git a/.changeset/api-error-formatting.md b/.changeset/api-error-formatting.md new file mode 100644 index 00000000..d3bcd0cf --- /dev/null +++ b/.changeset/api-error-formatting.md @@ -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. diff --git a/packages/cli-core/src/cli-program.test.ts b/packages/cli-core/src/cli-program.test.ts index b353340b..8c8cc613 100644 --- a/packages/cli-core/src/cli-program.test.ts +++ b/packages/cli-core/src/cli-program.test.ts @@ -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(); @@ -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"); }); @@ -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"); @@ -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"); }); @@ -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"); }); @@ -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"); }); @@ -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: [ { @@ -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 --- @@ -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"); }); @@ -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"); }); @@ -342,7 +341,7 @@ describe("formatApiBody", () => { }, ], }); - const result = formatApiBody(new ApiError(400, body), false); + const result = formatApiBody(body, false); expect(result).toBe("Plan limitation"); }); }); diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index a8e7f113..f1a46288 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -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; + } 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 { + 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) { @@ -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 =