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
25 changes: 16 additions & 9 deletions src/cli/telemetry/error-reporter.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { release, type } from "node:os";
import { determineAgent } from "@vercel/detect-agent";
import { nanoid } from "nanoid";
import { isCLIError, isUserError } from "@/core/errors.js";
import { ApiError, isCLIError, isUserError } from "@/core/errors.js";
import packageJson from "../../../package.json";
import { getPostHogClient, isTelemetryEnabled } from "./posthog.js";

Expand All @@ -20,10 +20,6 @@ export interface ErrorContext {
options: Record<string, unknown>;
};
appId?: string;
api?: {
statusCode?: number;
errorBody?: unknown;
};
}

export class ErrorReporter {
Expand Down Expand Up @@ -68,12 +64,24 @@ export class ErrorReporter {
}

private buildProperties(error?: Error): Record<string, unknown> {
const { user, command, appId, api } = this.context;
const { user, command, appId } = this.context;

// Extract CLIError-specific properties if applicable
const errorCode = error && isCLIError(error) ? error.code : undefined;
const userError = error ? isUserError(error) : undefined;

// Extract API request/response data from ApiError instances
const apiProps =
error instanceof ApiError
? {
api_status_code: error.statusCode,
api_request_url: error.requestUrl,
api_request_method: error.requestMethod,
api_request_body: error.requestBody,
api_response_body: error.responseBody,
}
: {};

return {
// Session
session_id: this.sessionId,
Expand All @@ -97,9 +105,8 @@ export class ErrorReporter {
is_user_error: userError,
}),

// API error
api_status_code: api?.statusCode,
api_error_body: api?.errorBody,
// API error (auto-extracted from ApiError)
...apiProps,

// System
cli_version: packageJson.version,
Expand Down
22 changes: 22 additions & 0 deletions src/core/clients/base44-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,27 @@ import { getAppConfig } from "@/core/project/index.js";
// Track requests that have already been retried to prevent infinite loops
const retriedRequests = new WeakSet<KyRequest>();

/**
* Captures request body for error reporting. Clones the request and reads the
* clone's body so the original is not consumed. Body is stored in options.context.__requestBody
* so it is available on HTTPError.options when ApiError.fromHttpError runs (for telemetry).
*/
async function captureRequestBody(
request: KyRequest,
options: NormalizedOptions
): Promise<void> {
if (request.body == null) {
return;
}
try {
const cloned = request.clone();
const text = await cloned.text();
options.context.__requestBody = text;
} catch {
// Ignore capture failures; request will still succeed
}
}

/**
* Handles 401 responses by refreshing the token and retrying the request.
* Only retries once per request to prevent infinite loops.
Expand Down Expand Up @@ -62,6 +83,7 @@ export const base44Client = ky.create({
},
hooks: {
beforeRequest: [
captureRequestBody,
async (request) => {
try {
const auth = await readAuth();
Expand Down
4 changes: 3 additions & 1 deletion src/core/clients/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ export const ApiErrorResponseSchema = z.object({
z.record(z.string(), z.unknown()),
z.array(z.unknown()),
])
.nullable()
.optional(),
traceback: z.string().optional(),
traceback: z.string().nullable().optional(),
extra_data: z.string().optional().nullable(),
});

export type ApiErrorResponse = z.infer<typeof ApiErrorResponseSchema>;
36 changes: 28 additions & 8 deletions src/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,30 +245,43 @@ export class InvalidInputError extends UserError {
// System Errors
// ============================================================================

export interface ApiErrorOptions extends CLIErrorOptions {
statusCode?: number;
requestUrl?: string;
requestMethod?: string;
requestBody?: unknown;
responseBody?: unknown;
}

/**
* Thrown when an API request fails.
*/
export class ApiError extends SystemError {
readonly code = "API_ERROR";
readonly statusCode?: number;
readonly requestUrl?: string;
readonly requestMethod?: string;
readonly requestBody?: unknown;
readonly responseBody?: unknown;

constructor(
message: string,
options?: CLIErrorOptions & { statusCode?: number }
) {
constructor(message: string, options?: ApiErrorOptions) {
const hints =
options?.hints ?? ApiError.getDefaultHints(options?.statusCode);
super(message, { hints, cause: options?.cause });
this.statusCode = options?.statusCode;
this.requestUrl = options?.requestUrl;
this.requestMethod = options?.requestMethod;
this.requestBody = options?.requestBody;
this.responseBody = options?.responseBody;
}

/**
* Creates an ApiError from a caught error (typically HTTPError from ky).
* Extracts status code and formats the error message from the response body.
* Extracts status code, request info, and response body for error reporting.
*
* @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)
* @returns ApiError with formatted message, status code, and request/response data
*
* @example
* try {
Expand All @@ -283,15 +296,22 @@ export class ApiError extends SystemError {
): Promise<ApiError> {
if (error instanceof HTTPError) {
let message: string;
let responseBody: unknown;
try {
const body: unknown = await error.response.clone().json();
message = formatApiError(body);
responseBody = await error.response.clone().json();
message = formatApiError(responseBody);
} catch {
message = error.message;
}

const requestBody = error.options.context?.__requestBody;

return new ApiError(`Error ${context}: ${message}`, {
statusCode: error.response.status,
requestUrl: error.request.url,
requestMethod: error.request.method,
requestBody,
responseBody,
cause: error,
});
}
Expand Down
106 changes: 106 additions & 0 deletions tests/core/errors.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,112 @@ describe("SystemError subclasses", () => {
);
});

it("ApiError stores request and response data", () => {
const responseBody = { error: "Bad Request", detail: "Invalid field" };
const requestBody = '{"name":"test"}';
const error = new ApiError("API failed", {
statusCode: 400,
requestUrl: "https://api.base44.com/v1/entities",
requestMethod: "POST",
requestBody,
responseBody,
});

expect(error.statusCode).toBe(400);
expect(error.requestUrl).toBe("https://api.base44.com/v1/entities");
expect(error.requestMethod).toBe("POST");
expect(error.requestBody).toBe(requestBody);
expect(error.responseBody).toEqual(responseBody);
});

it("ApiError has undefined request/response fields when not provided", () => {
const error = new ApiError("API failed", { statusCode: 500 });

expect(error.statusCode).toBe(500);
expect(error.requestUrl).toBeUndefined();
expect(error.requestMethod).toBeUndefined();
expect(error.requestBody).toBeUndefined();
expect(error.responseBody).toBeUndefined();
});

it("ApiError.fromHttpError extracts request and response data from HTTPError", async () => {
const { HTTPError } = await import("ky");
const responseBody = { message: "Not Found" };
const response = new Response(JSON.stringify(responseBody), {
status: 404,
statusText: "Not Found",
});
const request = new Request("https://api.base44.com/v1/apps/123", {
method: "GET",
});

const httpError = new HTTPError(response, request, {} as never);
const apiError = await ApiError.fromHttpError(httpError, "fetching app");

expect(apiError.statusCode).toBe(404);
expect(apiError.requestUrl).toBe("https://api.base44.com/v1/apps/123");
expect(apiError.requestMethod).toBe("GET");
expect(apiError.requestBody).toBeUndefined();
expect(apiError.responseBody).toEqual(responseBody);
expect(apiError.message).toContain("fetching app");
expect(apiError.message).toContain("Not Found");
});

it("ApiError.fromHttpError includes request body from options.context.__requestBody when present", async () => {
const { HTTPError } = await import("ky");
const responseBody = { message: "Bad Request" };
const response = new Response(JSON.stringify(responseBody), {
status: 400,
statusText: "Bad Request",
});
const request = new Request("https://api.base44.com/v1/entities", {
method: "POST",
});
const options = {
context: { __requestBody: '{"entities":[]}' },
} as never;

const httpError = new HTTPError(response, request, options);
const apiError = await ApiError.fromHttpError(
httpError,
"pushing entities"
);

expect(apiError.requestBody).toBe('{"entities":[]}');
});

it("ApiError.fromHttpError handles non-JSON response body", async () => {
const { HTTPError } = await import("ky");
const response = new Response("Internal Server Error", {
status: 500,
statusText: "Internal Server Error",
});
const request = new Request("https://api.base44.com/v1/deploy", {
method: "POST",
});

const httpError = new HTTPError(response, request, {} as never);
const apiError = await ApiError.fromHttpError(httpError, "deploying");

expect(apiError.statusCode).toBe(500);
expect(apiError.requestUrl).toBe("https://api.base44.com/v1/deploy");
expect(apiError.requestMethod).toBe("POST");
expect(apiError.responseBody).toBeUndefined();
});

it("ApiError.fromHttpError handles plain Error", async () => {
const error = new Error("Network timeout");
const apiError = await ApiError.fromHttpError(error, "connecting");

expect(apiError.statusCode).toBeUndefined();
expect(apiError.requestUrl).toBeUndefined();
expect(apiError.requestMethod).toBeUndefined();
expect(apiError.requestBody).toBeUndefined();
expect(apiError.responseBody).toBeUndefined();
expect(apiError.message).toContain("connecting");
expect(apiError.message).toContain("Network timeout");
});

it("FileNotFoundError has correct defaults", () => {
const error = new FileNotFoundError("File not found: /path/to/file");
expect(error.code).toBe("FILE_NOT_FOUND");
Expand Down
Loading