Improve CLI API connection errors
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
52dab938cb
commit
bd5c988728
2 changed files with 104 additions and 6 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
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", () => {
|
describe("PaperclipApiClient", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -59,6 +59,29 @@ describe("PaperclipApiClient", () => {
|
||||||
} satisfies Partial<ApiRequestError>);
|
} satisfies Partial<ApiRequestError>);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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<ApiConnectionError>);
|
||||||
|
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 () => {
|
it("retries once after interactive auth recovery", async () => {
|
||||||
const fetchMock = vi
|
const fetchMock = vi
|
||||||
.fn()
|
.fn()
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
interface RequestOptions {
|
||||||
ignoreNotFound?: boolean;
|
ignoreNotFound?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -76,6 +96,7 @@ export class PaperclipApiClient {
|
||||||
hasRetriedAuth = false,
|
hasRetriedAuth = false,
|
||||||
): Promise<T | null> {
|
): Promise<T | null> {
|
||||||
const url = buildUrl(this.apiBase, path);
|
const url = buildUrl(this.apiBase, path);
|
||||||
|
const method = String(init.method ?? "GET").toUpperCase();
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
accept: "application/json",
|
accept: "application/json",
|
||||||
|
|
@ -94,10 +115,20 @@ export class PaperclipApiClient {
|
||||||
headers["x-paperclip-run-id"] = this.runId;
|
headers["x-paperclip-run-id"] = this.runId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url, {
|
let response: Response;
|
||||||
...init,
|
try {
|
||||||
headers,
|
response = await fetch(url, {
|
||||||
});
|
...init,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new ApiConnectionError({
|
||||||
|
apiBase: this.apiBase,
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (opts?.ignoreNotFound && response.status === 404) {
|
if (opts?.ignoreNotFound && response.status === 404) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -108,7 +139,7 @@ export class PaperclipApiClient {
|
||||||
if (!hasRetriedAuth && this.recoverAuth) {
|
if (!hasRetriedAuth && this.recoverAuth) {
|
||||||
const recoveredToken = await this.recoverAuth({
|
const recoveredToken = await this.recoverAuth({
|
||||||
path,
|
path,
|
||||||
method: String(init.method ?? "GET").toUpperCase(),
|
method,
|
||||||
error: apiError,
|
error: apiError,
|
||||||
});
|
});
|
||||||
if (recoveredToken) {
|
if (recoveredToken) {
|
||||||
|
|
@ -166,6 +197,50 @@ async function toApiError(response: Response): Promise<ApiRequestError> {
|
||||||
return new ApiRequestError(response.status, `Request failed with status ${response.status}`, undefined, parsed);
|
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<string, string> {
|
function toStringRecord(headers: HeadersInit | undefined): Record<string, string> {
|
||||||
if (!headers) return {};
|
if (!headers) return {};
|
||||||
if (Array.isArray(headers)) {
|
if (Array.isArray(headers)) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue