From 5561a9c17f755a1365f0c7913960a44fe9c1df84 Mon Sep 17 00:00:00 2001 From: dotta Date: Tue, 24 Mar 2026 07:04:26 -0500 Subject: [PATCH] Improve CLI API connection errors Co-Authored-By: Paperclip --- cli/src/__tests__/http.test.ts | 25 +++++++++- cli/src/client/http.ts | 85 ++++++++++++++++++++++++++++++++-- 2 files changed, 104 insertions(+), 6 deletions(-) diff --git a/cli/src/__tests__/http.test.ts b/cli/src/__tests__/http.test.ts index c0b40e06..0bacec7d 100644 --- a/cli/src/__tests__/http.test.ts +++ b/cli/src/__tests__/http.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { ApiRequestError, PaperclipApiClient } from "../client/http.js"; +import { ApiConnectionError, ApiRequestError, PaperclipApiClient } from "../client/http.js"; describe("PaperclipApiClient", () => { afterEach(() => { @@ -59,6 +59,29 @@ describe("PaperclipApiClient", () => { } satisfies Partial); }); + it("throws ApiConnectionError with recovery guidance when fetch fails", async () => { + const fetchMock = vi.fn().mockRejectedValue(new TypeError("fetch failed")); + vi.stubGlobal("fetch", fetchMock); + + const client = new PaperclipApiClient({ apiBase: "http://localhost:3100" }); + + await expect(client.post("/api/companies/import/preview", {})).rejects.toBeInstanceOf(ApiConnectionError); + await expect(client.post("/api/companies/import/preview", {})).rejects.toMatchObject({ + url: "http://localhost:3100/api/companies/import/preview", + method: "POST", + causeMessage: "fetch failed", + } satisfies Partial); + await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow( + /Could not reach the Paperclip API\./, + ); + await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow( + /curl http:\/\/localhost:3100\/api\/health/, + ); + await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow( + /pnpm dev|pnpm paperclipai run/, + ); + }); + it("retries once after interactive auth recovery", async () => { const fetchMock = vi .fn() diff --git a/cli/src/client/http.ts b/cli/src/client/http.ts index 81cc0cfc..27de5eb1 100644 --- a/cli/src/client/http.ts +++ b/cli/src/client/http.ts @@ -13,6 +13,26 @@ export class ApiRequestError extends Error { } } +export class ApiConnectionError extends Error { + url: string; + method: string; + causeMessage?: string; + + constructor(input: { + apiBase: string; + path: string; + method: string; + cause?: unknown; + }) { + const url = buildUrl(input.apiBase, input.path); + const causeMessage = formatConnectionCause(input.cause); + super(buildConnectionErrorMessage({ apiBase: input.apiBase, url, method: input.method, causeMessage })); + this.url = url; + this.method = input.method; + this.causeMessage = causeMessage; + } +} + interface RequestOptions { ignoreNotFound?: boolean; } @@ -76,6 +96,7 @@ export class PaperclipApiClient { hasRetriedAuth = false, ): Promise { const url = buildUrl(this.apiBase, path); + const method = String(init.method ?? "GET").toUpperCase(); const headers: Record = { accept: "application/json", @@ -94,10 +115,20 @@ export class PaperclipApiClient { headers["x-paperclip-run-id"] = this.runId; } - const response = await fetch(url, { - ...init, - headers, - }); + let response: Response; + try { + response = await fetch(url, { + ...init, + headers, + }); + } catch (error) { + throw new ApiConnectionError({ + apiBase: this.apiBase, + path, + method, + cause: error, + }); + } if (opts?.ignoreNotFound && response.status === 404) { return null; @@ -108,7 +139,7 @@ export class PaperclipApiClient { if (!hasRetriedAuth && this.recoverAuth) { const recoveredToken = await this.recoverAuth({ path, - method: String(init.method ?? "GET").toUpperCase(), + method, error: apiError, }); if (recoveredToken) { @@ -166,6 +197,50 @@ async function toApiError(response: Response): Promise { return new ApiRequestError(response.status, `Request failed with status ${response.status}`, undefined, parsed); } +function buildConnectionErrorMessage(input: { + apiBase: string; + url: string; + method: string; + causeMessage?: string; +}): string { + const healthUrl = buildHealthCheckUrl(input.url); + const lines = [ + "Could not reach the Paperclip API.", + "", + `Request: ${input.method} ${input.url}`, + ]; + if (input.causeMessage) { + lines.push(`Cause: ${input.causeMessage}`); + } + lines.push( + "", + "This usually means the Paperclip server is not running, the configured URL is wrong, or the request is being blocked before it reaches Paperclip.", + "", + "Try:", + "- Start Paperclip with `pnpm dev` or `pnpm paperclipai run`.", + `- Verify the server is reachable with \`curl ${healthUrl}\`.`, + `- If Paperclip is running elsewhere, pass \`--api-base ${input.apiBase.replace(/\/+$/, "")}\` or set \`PAPERCLIP_API_URL\`.`, + ); + return lines.join("\n"); +} + +function buildHealthCheckUrl(requestUrl: string): string { + const url = new URL(requestUrl); + url.pathname = `${url.pathname.replace(/\/+$/, "").replace(/\/api(?:\/.*)?$/, "")}/api/health`; + url.search = ""; + url.hash = ""; + return url.toString(); +} + +function formatConnectionCause(error: unknown): string | undefined { + if (!error) return undefined; + if (error instanceof Error) { + return error.message.trim() || error.name; + } + const message = String(error).trim(); + return message || undefined; +} + function toStringRecord(headers: HeadersInit | undefined): Record { if (!headers) return {}; if (Array.isArray(headers)) {