Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<MyResponse> {
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()`:
Expand Down
12 changes: 4 additions & 8 deletions src/core/clients/base44-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApiErrorResponse> | 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<KyRequest>();
Expand Down Expand Up @@ -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(),
Expand Down
4 changes: 1 addition & 3 deletions src/core/clients/index.ts
Original file line number Diff line number Diff line change
@@ -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";
7 changes: 5 additions & 2 deletions src/core/clients/schemas.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ApiErrorSchema>;
export type ApiErrorResponse = z.infer<typeof ApiErrorResponseSchema>;
74 changes: 74 additions & 0 deletions src/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<ApiError> {
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" }];
Expand Down
40 changes: 25 additions & 15 deletions src/core/project/api.ts
Original file line number Diff line number Diff line change
@@ -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());

Expand All @@ -25,12 +30,17 @@ export async function createProject(projectName: string, description?: string) {
}

export async function listProjects(): Promise<ProjectsResponse> {
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());

Expand Down
32 changes: 13 additions & 19 deletions src/core/resources/agent/api.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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());
Expand All @@ -35,15 +32,12 @@ export async function pushAgents(

export async function fetchAgents(): Promise<ListAgentsResponse> {
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());
Expand Down
35 changes: 17 additions & 18 deletions src/core/resources/entity/api.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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());
Expand Down
15 changes: 10 additions & 5 deletions src/core/resources/function/api.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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());

Expand Down
Loading