diff --git a/AGENTS.md b/AGENTS.md index dd86d044..7325a494 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -494,6 +494,49 @@ throw new ApiError("Failed to sync entities", { statusCode: response.status }); // Other → hints to check network ``` +### API Error Handling Pattern + +When making HTTP requests with the ky client, use `ApiError.fromHttpError()` to convert HTTP errors to structured `ApiError` instances: + +```typescript +import { getAppClient } from "@/core/clients/index.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; +import { MyResponseSchema } from "./schema.js"; + +export async function myApiFunction(data: MyData): Promise { + const appClient = getAppClient(); + + let response; + try { + response = await appClient.put("endpoint", { json: data }); + } catch (error) { + throw await ApiError.fromHttpError(error, "performing action"); + } + + const result = MyResponseSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError("Invalid response from server", result.error); + } + + return result.data; +} +``` + +For status-specific handling (e.g., 428 for delete conflicts): + +```typescript +import { HTTPError } from "ky"; + +try { + response = await appClient.put("endpoint", { json: data }); +} catch (error) { + if (error instanceof HTTPError && error.response.status === 428) { + throw new ApiError("Cannot delete: resource has dependencies", { statusCode: 428, cause: error }); + } + throw await ApiError.fromHttpError(error, "performing action"); +} +``` + ### SchemaValidationError with Zod `SchemaValidationError` requires a context message and a `ZodError`. It formats the error automatically using `z.prettifyError()`: diff --git a/src/core/clients/base44-client.ts b/src/core/clients/base44-client.ts index 3b77516c..21b13364 100644 --- a/src/core/clients/base44-client.ts +++ b/src/core/clients/base44-client.ts @@ -12,13 +12,6 @@ import { isTokenExpired, } from "@/core/auth/config.js"; import { getAppConfig } from "@/core/project/index.js"; -import type { ApiErrorResponse } from "./schemas.js"; - -export function formatApiError(errorJson: unknown): string { - const error = errorJson as Partial | null; - const content = error?.message ?? error?.detail ?? errorJson; - return typeof content === "string" ? content : JSON.stringify(content, null, 2); -} // Track requests that have already been retried to prevent infinite loops const retriedRequests = new WeakSet(); @@ -56,8 +49,11 @@ async function handleUnauthorized( } /** - * Base44 API client with automatic authentication. + * Base44 API client with automatic authentication and error handling. * Use this for general API calls that require authentication. + * + * Note: HTTP errors are thrown as ky's HTTPError. Use ApiError.fromHttpError() + * in API functions to convert them to structured ApiError instances. */ export const base44Client = ky.create({ prefixUrl: getBase44ApiUrl(), diff --git a/src/core/clients/index.ts b/src/core/clients/index.ts index 2845b77f..a3912761 100644 --- a/src/core/clients/index.ts +++ b/src/core/clients/index.ts @@ -1,4 +1,2 @@ export { oauthClient } from "./oauth-client.js"; -export { base44Client, getAppClient, formatApiError } from "./base44-client.js"; -export { ApiErrorSchema } from "./schemas.js"; -export type { ApiErrorResponse } from "./schemas.js"; +export { base44Client, getAppClient } from "./base44-client.js"; diff --git a/src/core/clients/schemas.ts b/src/core/clients/schemas.ts index 45fa9e2e..c756acd7 100644 --- a/src/core/clients/schemas.ts +++ b/src/core/clients/schemas.ts @@ -1,10 +1,13 @@ import { z } from "zod"; -export const ApiErrorSchema = z.object({ +/** + * Schema for parsing API error responses from the Base44 backend. + */ +export const ApiErrorResponseSchema = z.object({ error_type: z.string().optional(), message: z.union([z.string(), z.record(z.string(), z.unknown())]).optional(), detail: z.union([z.string(), z.record(z.string(), z.unknown()), z.array(z.unknown())]).optional(), traceback: z.string().optional(), }); -export type ApiErrorResponse = z.infer; +export type ApiErrorResponse = z.infer; diff --git a/src/core/errors.ts b/src/core/errors.ts index 1f38d6f7..2b17d1e6 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -10,6 +10,42 @@ */ import { z } from "zod"; +import { HTTPError } from "ky"; +import { ApiErrorResponseSchema } from "@/core/clients/schemas.js"; + +// ============================================================================ +// API Error Response Parsing +// ============================================================================ + +/** + * Extracts a human-readable error message from an API error response body. + * Uses Zod schema to safely parse the response and extract message/detail. + * + * @param errorBody - The raw error response body (unknown type) + * @returns A formatted error message string + */ +export function formatApiError(errorBody: unknown): string { + const result = ApiErrorResponseSchema.safeParse(errorBody); + + if (result.success) { + const { message, detail } = result.data; + // Prefer message, fall back to detail + const content = message ?? detail; + if (typeof content === "string") { + return content; + } + if (content !== undefined) { + return JSON.stringify(content, null, 2); + } + } + + // Fallback for non-standard error responses + if (typeof errorBody === "string") { + return errorBody; + } + + return JSON.stringify(errorBody, null, 2); +} // ============================================================================ // Types @@ -194,6 +230,44 @@ export class ApiError extends SystemError { this.statusCode = options?.statusCode; } + /** + * Creates an ApiError from a caught error (typically HTTPError from ky). + * Extracts status code and formats the error message from the response body. + * + * @param error - The caught error (HTTPError, Error, or unknown) + * @param context - Description of what operation failed (e.g., "syncing agents") + * @returns ApiError with formatted message and status code (if available) + * + * @example + * try { + * const response = await appClient.get("endpoint"); + * } catch (error) { + * throw await ApiError.fromHttpError(error, "fetching data"); + * } + */ + static async fromHttpError(error: unknown, context: string): Promise { + if (error instanceof HTTPError) { + let message: string; + try { + const body: unknown = await error.response.clone().json(); + message = formatApiError(body); + } catch { + message = error.message; + } + + return new ApiError(`Error ${context}: ${message}`, { + statusCode: error.response.status, + cause: error, + }); + } + + if (error instanceof Error) { + return new ApiError(`Error ${context}: ${error.message}`, { cause: error }); + } + + return new ApiError(`Error ${context}: ${String(error)}`); + } + private static getDefaultHints(statusCode?: number): ErrorHint[] { if (statusCode === 401) { return [{ message: "Try logging in again", command: "base44 login" }]; diff --git a/src/core/project/api.ts b/src/core/project/api.ts index 07e0e523..bdab1d03 100644 --- a/src/core/project/api.ts +++ b/src/core/project/api.ts @@ -1,17 +1,22 @@ import { base44Client } from "@/core/clients/index.js"; import { CreateProjectResponseSchema, ProjectsResponseSchema } from "@/core/project/schema.js"; import type { ProjectsResponse } from "@/core/project/schema.js"; -import { SchemaValidationError } from "@/core/errors.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; export async function createProject(projectName: string, description?: string) { - const response = await base44Client.post("api/apps", { - json: { - name: projectName, - user_description: description ?? `Backend for '${projectName}'`, - is_managed_source_code: false, - public_settings: "public_without_login" - }, - }); + let response; + try { + response = await base44Client.post("api/apps", { + json: { + name: projectName, + user_description: description ?? `Backend for '${projectName}'`, + is_managed_source_code: false, + public_settings: "public_without_login", + }, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "creating project"); + } const result = CreateProjectResponseSchema.safeParse(await response.json()); @@ -25,12 +30,17 @@ export async function createProject(projectName: string, description?: string) { } export async function listProjects(): Promise { - const response = await base44Client.get(`api/apps`, { - searchParams: { - "sort": "-updated_date", - "fields": "id,name,user_description,is_managed_source_code" - } - }); + let response; + try { + response = await base44Client.get("api/apps", { + searchParams: { + sort: "-updated_date", + fields: "id,name,user_description,is_managed_source_code", + }, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "listing projects"); + } const result = ProjectsResponseSchema.safeParse(await response.json()); diff --git a/src/core/resources/agent/api.ts b/src/core/resources/agent/api.ts index 0c572fa0..28aa7ce8 100644 --- a/src/core/resources/agent/api.ts +++ b/src/core/resources/agent/api.ts @@ -1,4 +1,4 @@ -import { getAppClient, formatApiError } from "@/core/clients/index.js"; +import { getAppClient } from "@/core/clients/index.js"; import { SyncAgentsResponseSchema, ListAgentsResponseSchema } from "./schema.js"; import type { SyncAgentsResponse, AgentConfig, ListAgentsResponse } from "./schema.js"; import { ApiError, SchemaValidationError } from "@/core/errors.js"; @@ -12,16 +12,13 @@ export async function pushAgents( const appClient = getAppClient(); - const response = await appClient.put("agent-configs", { - json: agents, - throwHttpErrors: false, - }); - - if (!response.ok) { - const errorJson: unknown = await response.json(); - throw new ApiError(`Error occurred while syncing agents: ${formatApiError(errorJson)}`, { - statusCode: response.status, + let response; + try { + response = await appClient.put("agent-configs", { + json: agents, }); + } catch (error) { + throw await ApiError.fromHttpError(error, "syncing agents"); } const result = SyncAgentsResponseSchema.safeParse(await response.json()); @@ -35,15 +32,12 @@ export async function pushAgents( export async function fetchAgents(): Promise { const appClient = getAppClient(); - const response = await appClient.get("agent-configs", { - throwHttpErrors: false, - }); - - if (!response.ok) { - const errorJson: unknown = await response.json(); - throw new ApiError(`Error occurred while fetching agents: ${formatApiError(errorJson)}`, { - statusCode: response.status, - }); + + let response; + try { + response = await appClient.get("agent-configs"); + } catch (error) { + throw await ApiError.fromHttpError(error, "fetching agents"); } const result = ListAgentsResponseSchema.safeParse(await response.json()); diff --git a/src/core/resources/entity/api.ts b/src/core/resources/entity/api.ts index 033d5339..024d282f 100644 --- a/src/core/resources/entity/api.ts +++ b/src/core/resources/entity/api.ts @@ -1,4 +1,5 @@ -import { getAppClient, formatApiError } from "@/core/clients/index.js"; +import { HTTPError } from "ky"; +import { getAppClient } from "@/core/clients/index.js"; import { SyncEntitiesResponseSchema } from "@/core/resources/entity/schema.js"; import type { SyncEntitiesResponse, Entity } from "@/core/resources/entity/schema.js"; import { ApiError, SchemaValidationError } from "@/core/errors.js"; @@ -11,25 +12,23 @@ export async function syncEntities( entities.map((entity) => [entity.name, entity]) ); - const response = await appClient.put("entity-schemas", { - json: { - entityNameToSchema: schemaSyncPayload, - }, - throwHttpErrors: false, - }); - - if (!response.ok) { - const errorJson: unknown = await response.json(); - if (response.status === 428) { - throw new ApiError(`Failed to delete entity: ${formatApiError(errorJson)}`, { - statusCode: response.status, - }); + let response; + try { + response = await appClient.put("entity-schemas", { + json: { + entityNameToSchema: schemaSyncPayload, + }, + }); + } catch (error) { + // Handle 428 status code specifically (entity has records, can't delete) + if (error instanceof HTTPError && error.response.status === 428) { + throw new ApiError( + `Cannot delete entity that has existing records`, + { statusCode: 428, cause: error } + ); } - throw new ApiError( - `Error occurred while syncing entities: ${formatApiError(errorJson)}`, - { statusCode: response.status } - ); + throw await ApiError.fromHttpError(error, "syncing entities"); } const result = SyncEntitiesResponseSchema.safeParse(await response.json()); diff --git a/src/core/resources/function/api.ts b/src/core/resources/function/api.ts index 39b531ea..1f78592e 100644 --- a/src/core/resources/function/api.ts +++ b/src/core/resources/function/api.ts @@ -1,7 +1,7 @@ import { getAppClient } from "@/core/clients/index.js"; import { DeployFunctionsResponseSchema } from "@/core/resources/function/schema.js"; import type { FunctionWithCode, DeployFunctionsResponse } from "@/core/resources/function/schema.js"; -import { SchemaValidationError } from "@/core/errors.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; function toDeployPayloadItem(fn: FunctionWithCode) { return { @@ -19,10 +19,15 @@ export async function deployFunctions( functions: functions.map(toDeployPayloadItem), }; - const response = await appClient.put("backend-functions", { - json: payload, - timeout: 120_000 - }); + let response; + try { + response = await appClient.put("backend-functions", { + json: payload, + timeout: 120_000, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "deploying functions"); + } const result = DeployFunctionsResponseSchema.safeParse(await response.json()); diff --git a/src/core/site/api.ts b/src/core/site/api.ts index 2fdd8a17..983c74a4 100644 --- a/src/core/site/api.ts +++ b/src/core/site/api.ts @@ -2,14 +2,13 @@ import { getAppClient } from "@/core/clients/index.js"; import { readFile } from "@/core/utils/fs.js"; import { DeployResponseSchema } from "@/core/site/schema.js"; import type { DeployResponse } from "@/core/site/schema.js"; -import { SchemaValidationError } from "@/core/errors.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; /** * Uploads a tar.gz archive file to the Base44 hosting API. * * @param archivePath - Path to the tar.gz archive file * @returns Deploy response with the site URL and deployment details - * @throws Error if file read or upload fails */ export async function uploadSite(archivePath: string): Promise { const archiveBuffer = await readFile(archivePath); @@ -18,9 +17,15 @@ export async function uploadSite(archivePath: string): Promise { formData.append("file", blob, "dist.tar.gz"); const appClient = getAppClient(); - const response = await appClient.post("deploy-dist", { - body: formData, - }); + + let response; + try { + response = await appClient.post("deploy-dist", { + body: formData, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "deploying site"); + } const result = DeployResponseSchema.safeParse(await response.json()); diff --git a/tests/cli/functions_deploy.spec.ts b/tests/cli/functions_deploy.spec.ts index 3234d702..2b4ccb2c 100644 --- a/tests/cli/functions_deploy.spec.ts +++ b/tests/cli/functions_deploy.spec.ts @@ -40,6 +40,6 @@ describe("functions deploy command", () => { const result = await t.run("functions", "deploy"); t.expectResult(result).toFail(); - t.expectResult(result).toContain("400"); + t.expectResult(result).toContain("Invalid function code"); }); }); diff --git a/tests/core/agents.spec.ts b/tests/core/agents.spec.ts index 21544383..a78b932e 100644 --- a/tests/core/agents.spec.ts +++ b/tests/core/agents.spec.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { HTTPError } from "ky"; import type { AgentConfig } from "../../src/core/resources/agent/index.js"; import { pushAgents } from "../../src/core/resources/agent/api.js"; @@ -14,6 +15,18 @@ vi.mock("../../src/core/clients/index.js", async (importOriginal) => { }; }); +/** + * Creates a ky HTTPError for testing error handling. + */ +function createHTTPError(status: number, body: unknown): HTTPError { + const response = new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + const request = new Request("https://api.base44.com/test"); + // Use type assertion to satisfy ky's NormalizedOptions requirement + return new HTTPError(response, request, {} as never); +} describe("pushAgents", () => { beforeEach(() => { @@ -33,15 +46,15 @@ describe("pushAgents", () => { name: "test_agent", description: "Test", instructions: "Do stuff", - tool_configs: [{ allowed_operations: ["read", "create", "update", "delete"], entity_name: "User" }], + tool_configs: [ + { allowed_operations: ["read", "create", "update", "delete"], entity_name: "User" }, + ], whatsapp_greeting: "Hello!", }, ]; mockPut.mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ created: ["test_agent"], updated: [], deleted: [] }), + json: () => Promise.resolve({ created: ["test_agent"], updated: [], deleted: [] }), }); const result = await pushAgents(agents); @@ -52,11 +65,12 @@ describe("pushAgents", () => { name: "test_agent", description: "Test", instructions: "Do stuff", - tool_configs: [{ allowed_operations: ["read", "create", "update", "delete"], entity_name: "User" }], + tool_configs: [ + { allowed_operations: ["read", "create", "update", "delete"], entity_name: "User" }, + ], whatsapp_greeting: "Hello!", }, ], - throwHttpErrors: false, }); expect(result.created).toEqual(["test_agent"]); }); @@ -72,9 +86,7 @@ describe("pushAgents", () => { ]; mockPut.mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ created: ["agent_no_greeting"], updated: [], deleted: [] }), + json: () => Promise.resolve({ created: ["agent_no_greeting"], updated: [], deleted: [] }), }); await pushAgents(agents); @@ -88,11 +100,10 @@ describe("pushAgents", () => { tool_configs: [], }, ], - throwHttpErrors: false, }); }); - it("throws error with message when API returns error", async () => { + it("throws ApiError with message when API returns error", async () => { const agents: AgentConfig[] = [ { name: "test_agent", @@ -102,18 +113,15 @@ describe("pushAgents", () => { }, ]; - mockPut.mockResolvedValue({ - ok: false, - json: () => Promise.resolve({ + mockPut.mockRejectedValue( + createHTTPError(401, { error_type: "HTTPException", message: "Unauthorized access", detail: "Token expired", - }), - }); - - await expect(pushAgents(agents)).rejects.toThrow( - "Error occurred while syncing agents: Unauthorized access" + }) ); + + await expect(pushAgents(agents)).rejects.toThrow("Error syncing agents: Unauthorized access"); }); it("falls back to detail when message is not present", async () => { @@ -126,14 +134,9 @@ describe("pushAgents", () => { }, ]; - mockPut.mockResolvedValue({ - ok: false, - json: () => Promise.resolve({ detail: "Some error detail" }), - }); + mockPut.mockRejectedValue(createHTTPError(400, { detail: "Some error detail" })); - await expect(pushAgents(agents)).rejects.toThrow( - "Error occurred while syncing agents: Some error detail" - ); + await expect(pushAgents(agents)).rejects.toThrow("Error syncing agents: Some error detail"); }); it("stringifies object errors", async () => { @@ -146,17 +149,16 @@ describe("pushAgents", () => { }, ]; - mockPut.mockResolvedValue({ - ok: false, - json: () => Promise.resolve({ + mockPut.mockRejectedValue( + createHTTPError(422, { error_type: "ValidationError", message: { field: "name", error: "required" }, detail: [{ loc: ["name"], msg: "field required" }], - }), - }); + }) + ); await expect(pushAgents(agents)).rejects.toThrow( - 'Error occurred while syncing agents: {\n "field": "name",\n "error": "required"\n}' + 'Error syncing agents: {\n "field": "name",\n "error": "required"\n}' ); }); }); diff --git a/tests/core/errors.spec.ts b/tests/core/errors.spec.ts index 9a6f5390..107e4f51 100644 --- a/tests/core/errors.spec.ts +++ b/tests/core/errors.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; -import { formatApiError } from "../../src/core/clients/index.js"; import { + formatApiError, AuthRequiredError, AuthExpiredError, ConfigNotFoundError,