From 13985be5c1f70cb35e147be36b7c8e68485df69d Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 27 Jan 2026 13:45:30 +0000 Subject: [PATCH 1/3] refactor: centralize API error handling in ky client This commit moves API error handling logic directly into the base ky client using the beforeError hook, eliminating the need to manually call formatApiError in every API method. Changes: - Made formatApiError internal to base44-client.ts - Added handleApiErrors hook using ky's beforeError mechanism - Updated base44Client to include error handling in beforeError hooks - Removed formatApiError export from index.ts - Simplified agent/api.ts by removing manual error handling - Simplified entity/api.ts by removing manual error handling - Preserved 428 status code handling in syncEntities - Removed tests/core/errors.test.ts as formatApiError is now internal Benefits: - Reduces boilerplate in API client methods - Ensures consistent error handling across all API calls - Centralizes error formatting logic - Makes the codebase easier to maintain Co-authored-by: paveltarno --- src/core/clients/base44-client.ts | 49 +++++++++++++++++++++++++++++-- src/core/clients/index.ts | 2 +- src/core/resources/agent/api.ts | 36 +++++++++++------------ src/core/resources/entity/api.ts | 33 +++++++++++---------- 4 files changed, 81 insertions(+), 39 deletions(-) diff --git a/src/core/clients/base44-client.ts b/src/core/clients/base44-client.ts index 3b77516c..256c23be 100644 --- a/src/core/clients/base44-client.ts +++ b/src/core/clients/base44-client.ts @@ -12,9 +12,13 @@ import { isTokenExpired, } from "@/core/auth/config.js"; import { getAppConfig } from "@/core/project/index.js"; -import type { ApiErrorResponse } from "./schemas.js"; +import { ApiErrorSchema, type ApiErrorResponse } from "./schemas.js"; -export function formatApiError(errorJson: unknown): string { +/** + * Formats API error responses into human-readable strings. + * Internal utility used by error handling hooks. + */ +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); @@ -56,8 +60,46 @@ async function handleUnauthorized( } /** - * Base44 API client with automatic authentication. + * Handles HTTPErrors by formatting the API error response into a readable message. + * This hook runs before ky throws the error, allowing us to customize the error message. + */ +async function handleApiErrors(error: Error): Promise { + // Only handle HTTPError from ky + if (error.name !== "HTTPError") { + return error; + } + + // Cast to access response property + const httpError = error as Error & { response?: Response }; + + if (!httpError.response) { + return error; + } + + // Try to parse the error response body + try { + const errorJson: unknown = await httpError.response.clone().json(); + const formattedMessage = formatApiError(errorJson); + + // Create a new error with the formatted message + const newError = new Error(formattedMessage); + newError.name = error.name; + newError.stack = error.stack; + + // Preserve the original response for debugging + (newError as typeof httpError).response = httpError.response; + + return newError; + } catch { + // If we can't parse the body, return the original error + return error; + } +} + +/** + * Base44 API client with automatic authentication and error handling. * Use this for general API calls that require authentication. + * All non-OK responses are automatically caught and formatted into Error objects. */ export const base44Client = ky.create({ prefixUrl: getBase44ApiUrl(), @@ -86,6 +128,7 @@ export const base44Client = ky.create({ }, ], afterResponse: [handleUnauthorized], + beforeError: [handleApiErrors], }, }); diff --git a/src/core/clients/index.ts b/src/core/clients/index.ts index 2845b77f..6c3ab25f 100644 --- a/src/core/clients/index.ts +++ b/src/core/clients/index.ts @@ -1,4 +1,4 @@ export { oauthClient } from "./oauth-client.js"; -export { base44Client, getAppClient, formatApiError } from "./base44-client.js"; +export { base44Client, getAppClient } from "./base44-client.js"; export { ApiErrorSchema } from "./schemas.js"; export type { ApiErrorResponse } from "./schemas.js"; diff --git a/src/core/resources/agent/api.ts b/src/core/resources/agent/api.ts index 0c572fa0..c407c4dc 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,15 @@ 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 new ApiError( + `Error occurred while syncing agents: ${error instanceof Error ? error.message : String(error)}` + ); } const result = SyncAgentsResponseSchema.safeParse(await response.json()); @@ -35,15 +34,14 @@ 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 new ApiError( + `Error occurred while fetching agents: ${error instanceof Error ? error.message : String(error)}` + ); } 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..305690ce 100644 --- a/src/core/resources/entity/api.ts +++ b/src/core/resources/entity/api.ts @@ -1,7 +1,8 @@ -import { getAppClient, formatApiError } from "@/core/clients/index.js"; +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"; +import { HTTPError } from "ky"; export async function syncEntities( entities: Entity[] @@ -11,24 +12,24 @@ 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 + if (error instanceof HTTPError && error.response.status === 428) { + throw new ApiError( + `Failed to delete entity: ${error instanceof Error ? error.message : String(error)}`, + { statusCode: 428 } + ); } throw new ApiError( - `Error occurred while syncing entities: ${formatApiError(errorJson)}`, - { statusCode: response.status } + `Error occurred while syncing entities: ${error instanceof Error ? error.message : String(error)}` ); } From 07d5d9b936edb90f8b29daf07e3ad3d23577d45e Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Sun, 1 Feb 2026 10:07:05 +0200 Subject: [PATCH 2/3] added api error handeling --- src/core/clients/base44-client.ts | 53 +-------------- src/core/clients/index.ts | 2 - src/core/clients/schemas.ts | 7 +- src/core/errors.ts | 102 +++++++++++++++++++++++++++++ src/core/project/api.ts | 40 ++++++----- src/core/resources/agent/api.ts | 8 +-- src/core/resources/entity/api.ts | 12 ++-- src/core/resources/function/api.ts | 15 +++-- src/core/site/api.ts | 15 +++-- tests/cli/functions_deploy.spec.ts | 2 +- tests/core/agents.spec.ts | 72 +++++++++++--------- tests/core/errors.spec.ts | 2 +- 12 files changed, 204 insertions(+), 126 deletions(-) diff --git a/src/core/clients/base44-client.ts b/src/core/clients/base44-client.ts index 256c23be..21b13364 100644 --- a/src/core/clients/base44-client.ts +++ b/src/core/clients/base44-client.ts @@ -12,17 +12,6 @@ import { isTokenExpired, } from "@/core/auth/config.js"; import { getAppConfig } from "@/core/project/index.js"; -import { ApiErrorSchema, type ApiErrorResponse } from "./schemas.js"; - -/** - * Formats API error responses into human-readable strings. - * Internal utility used by error handling hooks. - */ -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(); @@ -59,47 +48,12 @@ async function handleUnauthorized( }); } -/** - * Handles HTTPErrors by formatting the API error response into a readable message. - * This hook runs before ky throws the error, allowing us to customize the error message. - */ -async function handleApiErrors(error: Error): Promise { - // Only handle HTTPError from ky - if (error.name !== "HTTPError") { - return error; - } - - // Cast to access response property - const httpError = error as Error & { response?: Response }; - - if (!httpError.response) { - return error; - } - - // Try to parse the error response body - try { - const errorJson: unknown = await httpError.response.clone().json(); - const formattedMessage = formatApiError(errorJson); - - // Create a new error with the formatted message - const newError = new Error(formattedMessage); - newError.name = error.name; - newError.stack = error.stack; - - // Preserve the original response for debugging - (newError as typeof httpError).response = httpError.response; - - return newError; - } catch { - // If we can't parse the body, return the original error - return error; - } -} - /** * Base44 API client with automatic authentication and error handling. * Use this for general API calls that require authentication. - * All non-OK responses are automatically caught and formatted into Error objects. + * + * 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(), @@ -128,7 +82,6 @@ export const base44Client = ky.create({ }, ], afterResponse: [handleUnauthorized], - beforeError: [handleApiErrors], }, }); diff --git a/src/core/clients/index.ts b/src/core/clients/index.ts index 6c3ab25f..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 } from "./base44-client.js"; -export { ApiErrorSchema } from "./schemas.js"; -export type { ApiErrorResponse } from "./schemas.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..78a40077 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -10,6 +10,41 @@ */ import { z } from "zod"; +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 @@ -181,6 +216,35 @@ export class InvalidInputError extends UserError { // System Errors // ============================================================================ +/** + * HTTP error-like interface for parsing errors from HTTP clients. + * Compatible with ky's HTTPError without requiring a direct dependency. + */ +interface HttpErrorLike { + name: string; + message: string; + response: { + status: number; + clone: () => { json: () => Promise }; + }; +} + +/** + * Type guard to check if an error looks like an HTTP error (e.g., from ky). + */ +function isHttpError(error: unknown): error is HttpErrorLike { + return ( + error !== null && + typeof error === "object" && + "name" in error && + error.name === "HTTPError" && + "response" in error && + typeof error.response === "object" && + error.response !== null && + "status" in error.response + ); +} + /** * Thrown when an API request fails. */ @@ -194,6 +258,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 (isHttpError(error)) { + 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 as 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 c407c4dc..28aa7ce8 100644 --- a/src/core/resources/agent/api.ts +++ b/src/core/resources/agent/api.ts @@ -18,9 +18,7 @@ export async function pushAgents( json: agents, }); } catch (error) { - throw new ApiError( - `Error occurred while syncing agents: ${error instanceof Error ? error.message : String(error)}` - ); + throw await ApiError.fromHttpError(error, "syncing agents"); } const result = SyncAgentsResponseSchema.safeParse(await response.json()); @@ -39,9 +37,7 @@ export async function fetchAgents(): Promise { try { response = await appClient.get("agent-configs"); } catch (error) { - throw new ApiError( - `Error occurred while fetching agents: ${error instanceof Error ? error.message : String(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 305690ce..024d282f 100644 --- a/src/core/resources/entity/api.ts +++ b/src/core/resources/entity/api.ts @@ -1,8 +1,8 @@ +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"; -import { HTTPError } from "ky"; export async function syncEntities( entities: Entity[] @@ -20,17 +20,15 @@ export async function syncEntities( }, }); } catch (error) { - // Handle 428 status code specifically + // Handle 428 status code specifically (entity has records, can't delete) if (error instanceof HTTPError && error.response.status === 428) { throw new ApiError( - `Failed to delete entity: ${error instanceof Error ? error.message : String(error)}`, - { statusCode: 428 } + `Cannot delete entity that has existing records`, + { statusCode: 428, cause: error } ); } - throw new ApiError( - `Error occurred while syncing entities: ${error instanceof Error ? error.message : String(error)}` - ); + 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..a29517db 100644 --- a/tests/core/agents.spec.ts +++ b/tests/core/agents.spec.ts @@ -14,6 +14,25 @@ vi.mock("../../src/core/clients/index.js", async (importOriginal) => { }; }); +/** + * Creates a mock HTTP error object for testing error handling. + * Simulates ky's HTTPError structure without requiring exact type match. + */ +function createMockHTTPError(status: number, body: unknown) { + const response = new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + + const error = new Error(`Request failed with status code ${status}`) as Error & { + name: string; + response: Response; + }; + error.name = "HTTPError"; + error.response = response; + + return error; +} describe("pushAgents", () => { beforeEach(() => { @@ -33,15 +52,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 +71,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 +92,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 +106,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 +119,15 @@ describe("pushAgents", () => { }, ]; - mockPut.mockResolvedValue({ - ok: false, - json: () => Promise.resolve({ + mockPut.mockRejectedValue( + createMockHTTPError(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 +140,9 @@ describe("pushAgents", () => { }, ]; - mockPut.mockResolvedValue({ - ok: false, - json: () => Promise.resolve({ detail: "Some error detail" }), - }); + mockPut.mockRejectedValue(createMockHTTPError(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 +155,16 @@ describe("pushAgents", () => { }, ]; - mockPut.mockResolvedValue({ - ok: false, - json: () => Promise.resolve({ + mockPut.mockRejectedValue( + createMockHTTPError(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, From 1b30655267b7b6d88ff6df212884599587cd309d Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Sun, 1 Feb 2026 10:21:36 +0200 Subject: [PATCH 3/3] pr changes --- AGENTS.md | 43 +++++++++++++++++++++++++++++++++++++++ src/core/errors.ts | 34 +++---------------------------- tests/core/agents.spec.ts | 24 ++++++++-------------- 3 files changed, 55 insertions(+), 46 deletions(-) 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/errors.ts b/src/core/errors.ts index 78a40077..2b17d1e6 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -10,6 +10,7 @@ */ import { z } from "zod"; +import { HTTPError } from "ky"; import { ApiErrorResponseSchema } from "@/core/clients/schemas.js"; // ============================================================================ @@ -216,35 +217,6 @@ export class InvalidInputError extends UserError { // System Errors // ============================================================================ -/** - * HTTP error-like interface for parsing errors from HTTP clients. - * Compatible with ky's HTTPError without requiring a direct dependency. - */ -interface HttpErrorLike { - name: string; - message: string; - response: { - status: number; - clone: () => { json: () => Promise }; - }; -} - -/** - * Type guard to check if an error looks like an HTTP error (e.g., from ky). - */ -function isHttpError(error: unknown): error is HttpErrorLike { - return ( - error !== null && - typeof error === "object" && - "name" in error && - error.name === "HTTPError" && - "response" in error && - typeof error.response === "object" && - error.response !== null && - "status" in error.response - ); -} - /** * Thrown when an API request fails. */ @@ -274,7 +246,7 @@ export class ApiError extends SystemError { * } */ static async fromHttpError(error: unknown, context: string): Promise { - if (isHttpError(error)) { + if (error instanceof HTTPError) { let message: string; try { const body: unknown = await error.response.clone().json(); @@ -285,7 +257,7 @@ export class ApiError extends SystemError { return new ApiError(`Error ${context}: ${message}`, { statusCode: error.response.status, - cause: error as Error, + cause: error, }); } diff --git a/tests/core/agents.spec.ts b/tests/core/agents.spec.ts index a29517db..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"; @@ -15,23 +16,16 @@ vi.mock("../../src/core/clients/index.js", async (importOriginal) => { }); /** - * Creates a mock HTTP error object for testing error handling. - * Simulates ky's HTTPError structure without requiring exact type match. + * Creates a ky HTTPError for testing error handling. */ -function createMockHTTPError(status: number, body: unknown) { +function createHTTPError(status: number, body: unknown): HTTPError { const response = new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" }, }); - - const error = new Error(`Request failed with status code ${status}`) as Error & { - name: string; - response: Response; - }; - error.name = "HTTPError"; - error.response = response; - - return error; + 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", () => { @@ -120,7 +114,7 @@ describe("pushAgents", () => { ]; mockPut.mockRejectedValue( - createMockHTTPError(401, { + createHTTPError(401, { error_type: "HTTPException", message: "Unauthorized access", detail: "Token expired", @@ -140,7 +134,7 @@ describe("pushAgents", () => { }, ]; - mockPut.mockRejectedValue(createMockHTTPError(400, { detail: "Some error detail" })); + mockPut.mockRejectedValue(createHTTPError(400, { detail: "Some error detail" })); await expect(pushAgents(agents)).rejects.toThrow("Error syncing agents: Some error detail"); }); @@ -156,7 +150,7 @@ describe("pushAgents", () => { ]; mockPut.mockRejectedValue( - createMockHTTPError(422, { + createHTTPError(422, { error_type: "ValidationError", message: { field: "name", error: "required" }, detail: [{ loc: ["name"], msg: "field required" }],