From a59ad5b350ddf1f39dc63aee5fe67ee0420641f7 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 24 Jul 2026 19:56:59 +0000 Subject: [PATCH 1/7] feat(error): add top level error class with classification at top-level --- bun.lock | 1 + package.json | 1 + src/errors/index.ts | 66 ++++++++++++++++++++++++++++++++++++++ src/runnable/index.test.ts | 30 ++++++++++------- src/runnable/index.tsx | 17 ++++------ 5 files changed, 94 insertions(+), 21 deletions(-) create mode 100644 src/errors/index.ts diff --git a/bun.lock b/bun.lock index f001ff39c..506ee27a8 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@aws-sdk/client-bedrock-agentcore-control": "^3.1079.0", "@aws-sdk/client-iam": "^3.1080.0", "@inkui-cli/data-table": "^0.2.0", + "@smithy/core": "3.29.3", "@tanstack/react-query": "^5.101.2", "commander": "^15.0.0", "ink": "^7.1.0", diff --git a/package.json b/package.json index 9a5e0d80e..9de045001 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@aws-sdk/client-bedrock-agentcore-control": "^3.1079.0", "@aws-sdk/client-iam": "^3.1080.0", "@inkui-cli/data-table": "^0.2.0", + "@smithy/core": "3.29.3", "@tanstack/react-query": "^5.101.2", "commander": "^15.0.0", "ink": "^7.1.0", diff --git a/src/errors/index.ts b/src/errors/index.ts new file mode 100644 index 000000000..cf58c0aab --- /dev/null +++ b/src/errors/index.ts @@ -0,0 +1,66 @@ +import { ServiceException } from "@smithy/core/client"; + +export const ERROR_SOURCE = { + // note: this maps to the `client` error source in telemetry. + INTERNAL: "internal", + USER: "user", + SERVICE: "service", + UNKNOWN: "unknown", +} as const; + +export type ErrorSource = (typeof ERROR_SOURCE)[keyof typeof ERROR_SOURCE]; + +export interface AgentCoreCLIErrorOptions extends ErrorOptions { + source?: ErrorSource; + meta?: Record; + exitCode?: number; +} + +/** Base error for CLI failures, including their source, metadata, and process exit code. */ +export class AgentCoreCLIError extends Error { + readonly source: ErrorSource; + readonly meta: Record; + readonly exitCode: number; + + constructor(message?: string, options?: AgentCoreCLIErrorOptions) { + super(message, options); + this.name = new.target.name; + this.source = options?.source ?? ERROR_SOURCE.UNKNOWN; + this.meta = options?.meta ?? {}; + this.exitCode = options?.exitCode ?? 1; + } +} + +/** Error raised for invalid user input. */ +export class InputValidationError extends AgentCoreCLIError { + constructor(message?: string, options?: Omit) { + super(message, { ...options, source: ERROR_SOURCE.USER }); + } +} + +/** Converts any thrown value into an {@link AgentCoreCLIError}, preserving known CLI errors. */ +export function classify(error: unknown): AgentCoreCLIError { + if (error instanceof AgentCoreCLIError) { + return error; + } + + if (ServiceException.isInstance(error)) { + const statusCode = error.$metadata.httpStatusCode; + + const source = + statusCode !== undefined && statusCode >= 400 && statusCode < 500 + ? ERROR_SOURCE.USER + : ERROR_SOURCE.SERVICE; + + return new AgentCoreCLIError(error.message, { + cause: error, + source, + // note: we store the original name in meta so we can pull it out in telemetry. + meta: { ...error.$metadata, name: error.name }, + }); + } + + if (error instanceof Error) return new AgentCoreCLIError(error.message, { cause: error }); + + return new AgentCoreCLIError(String(error), { cause: error }); +} diff --git a/src/runnable/index.test.ts b/src/runnable/index.test.ts index e9d58ec1d..f756a6e62 100644 --- a/src/runnable/index.test.ts +++ b/src/runnable/index.test.ts @@ -1,8 +1,9 @@ -import { test, expect } from "bun:test"; +import { expect, test } from "bun:test"; -import { runRunnable, runWithExitCode, ExitCode, type Runnable } from "./index.tsx"; +import { AgentCoreCLIError } from "../errors"; +import { runRunnable, type Runnable } from "./index.tsx"; -test("returns SUCCESS and forwards argv when run completes", async () => { +test("returns zero and forwards argv when run completes", async () => { let receivedArgv: string[] | undefined; const runnable: Runnable = { run: async (argv: string[]) => { @@ -13,11 +14,11 @@ test("returns SUCCESS and forwards argv when run completes", async () => { const argv = ["node", "script", "--flag"]; const code = await runRunnable(() => runnable, argv); - expect(code).toBe(ExitCode.SUCCESS); + expect(code).toBe(0); expect(receivedArgv).toEqual(argv); }); -test("returns FAILURE when run rejects with an Error", async () => { +test("returns the default failure code when run rejects with an Error", async () => { const runnable: Runnable = { run: async () => { throw new Error("boom"); @@ -26,18 +27,25 @@ test("returns FAILURE when run rejects with an Error", async () => { const code = await runRunnable(() => runnable, []); - expect(code).toBe(ExitCode.FAILURE); + expect(code).toBe(1); }); -test("returns FAILURE when the factory throws a non-Error value", async () => { +test("returns the default failure code when the factory throws a non-Error value", async () => { const code = await runRunnable(() => { throw "kaboom"; }, []); - expect(code).toBe(ExitCode.FAILURE); + expect(code).toBe(1); }); -test("runWithExitCode returns SUCCESS for a resolving function", async () => { - const code = await runWithExitCode(async () => {}); - expect(code).toBe(ExitCode.SUCCESS); +test("respects custom errors codes from known errors", async () => { + const runnable: Runnable = { + run: async () => { + throw new AgentCoreCLIError("custom failure", { exitCode: 42 }); + }, + }; + + const code = await runRunnable(() => runnable, []); + + expect(code).toBe(42); }); diff --git a/src/runnable/index.tsx b/src/runnable/index.tsx index 51fef1ec6..c26097456 100644 --- a/src/runnable/index.tsx +++ b/src/runnable/index.tsx @@ -1,8 +1,4 @@ -// ExitCode provides names for default Unix exit codes. -export enum ExitCode { - SUCCESS = 0, - FAILURE = 1, -} +import { classify } from "../errors"; // Runnable can be implemented by any application's main entrypoint. export interface Runnable { @@ -13,7 +9,7 @@ export interface Runnable { export function runRunnable( createRunnable: () => Runnable, argv: string[] = process.argv, -): Promise { +): Promise { return runWithExitCode(async () => { await createRunnable().run(argv); }); @@ -23,13 +19,14 @@ export function runRunnable( export async function runWithExitCode( fn: (argv: string[]) => Promise, argv: string[] = process.argv, -): Promise { +): Promise { try { await fn(argv); - return ExitCode.SUCCESS; + return 0; } catch (e) { - const error = e instanceof Error ? e : new Error(String(e)); + const error = classify(e); console.error(`${error.name}: ${error.message}`); - return ExitCode.FAILURE; + + return error.exitCode; } } From b46518d7211a7171ff03ce9fd90524bc0e9ee9c3 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 24 Jul 2026 19:57:20 +0000 Subject: [PATCH 2/7] fix(logging): avoid redundant error log in middleware --- src/middleware/withLogging.tsx | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/middleware/withLogging.tsx b/src/middleware/withLogging.tsx index 2b89084de..ca7342da7 100644 --- a/src/middleware/withLogging.tsx +++ b/src/middleware/withLogging.tsx @@ -39,18 +39,11 @@ export function withLogging(config: WithLoggingConfig): Middleware { handle: async (ctx, flags, args) => { const commandPath = ctx.require(PathKey); const logger = config.logger.child({ commandPath }); - try { - const safeFlags = redactSensitiveFlags(flags, h.flags()); - logger.child({ flags: safeFlags, args }).debug("executing command"); - await h.handle(ctx.withValue(LoggerKey, logger), flags, args); - logger.debug("command executed successfully"); - } catch (err) { - const error = err instanceof Error ? err : new Error(String(err)); - logger - .child({ errorName: error.name, errorMessage: error.message, stack: error.stack ?? "" }) - .error("command failed"); - throw err; - } + const safeFlags = redactSensitiveFlags(flags, h.flags()); + + logger.child({ flags: safeFlags, args }).debug("executing command"); + await h.handle(ctx.withValue(LoggerKey, logger), flags, args); + logger.debug("command executed successfully"); }, }); } From eca0449c183b918d73b3db18edeb107b17b9b278 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Sat, 25 Jul 2026 14:58:47 +0000 Subject: [PATCH 3/7] refactor(errors): move classification into src/index for future telemetry support --- src/errors/index.ts | 20 ++++++++++++++------ src/index.ts | 11 +++++------ src/middleware/withLogging.test.ts | 26 +++----------------------- src/middleware/withLogging.tsx | 2 +- src/runnable/index.tsx | 6 +++--- 5 files changed, 26 insertions(+), 39 deletions(-) diff --git a/src/errors/index.ts b/src/errors/index.ts index cf58c0aab..d4458ae30 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -29,6 +29,17 @@ export class AgentCoreCLIError extends Error { this.meta = options?.meta ?? {}; this.exitCode = options?.exitCode ?? 1; } + /** Convert the error into an object with its attributes enumerated as keys **/ + json(): Record { + return { + name: this.name, + message: this.message, + stack: this.stack, + exitCode: this.exitCode, + meta: this.meta, + source: this.source, + }; + } } /** Error raised for invalid user input. */ @@ -40,15 +51,12 @@ export class InputValidationError extends AgentCoreCLIError { /** Converts any thrown value into an {@link AgentCoreCLIError}, preserving known CLI errors. */ export function classify(error: unknown): AgentCoreCLIError { - if (error instanceof AgentCoreCLIError) { - return error; - } + if (error instanceof AgentCoreCLIError) return error; if (ServiceException.isInstance(error)) { - const statusCode = error.$metadata.httpStatusCode; - + const httpStatusCode = error.$metadata.httpStatusCode; const source = - statusCode !== undefined && statusCode >= 400 && statusCode < 500 + httpStatusCode !== undefined && httpStatusCode >= 400 && httpStatusCode < 500 ? ERROR_SOURCE.USER : ERROR_SOURCE.SERVICE; diff --git a/src/index.ts b/src/index.ts index b76e22fd2..4d301e35f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import { createRootHandler } from "./handlers"; import { createFileLogger, LOG_LEVEL } from "./logging"; import { runWithExitCode } from "./runnable"; import { DefaultGlobalConfigAccessor, FsReadWriteJson } from "./globalConfig"; +import { classify } from "./errors"; process.exit( await runWithExitCode(async (argv: string[]) => { @@ -54,7 +55,7 @@ process.exit( // Pass it to the root handler, along with the process's standard streams as // the app's io. CoreClient exposes feature sub-clients (e.g. `.harness`), so - // it satisfies the Core contract directly. + // it satisfies the Core contract directly.u const rootHandler = createRootHandler(coreClient, { io, logger: rootLogger, @@ -64,11 +65,9 @@ process.exit( // Handle the request await rootHandler.route(argv); } catch (e) { - const error = e instanceof Error ? e : new Error(String(e)); - rootLogger - .child({ errorName: error.name, errorMessage: error.message, stack: error.stack ?? "" }) - .error(); - throw e; + const error = classify(e); + rootLogger.child({ error: error.json() }).error(); + throw error; } finally { await rootLogger.end(); } diff --git a/src/middleware/withLogging.test.ts b/src/middleware/withLogging.test.ts index 68e56af0d..94359f865 100644 --- a/src/middleware/withLogging.test.ts +++ b/src/middleware/withLogging.test.ts @@ -62,7 +62,7 @@ describe("withLogging", () => { ]); }); - test("logs success and error with correct command path bindings", async () => { + test("logs success with the correct command path binding", async () => { const app = new Router("myapp", "test app"); app.use(withLogging({ logger })); app.handler( @@ -72,33 +72,13 @@ describe("withLogging", () => { handle: async () => {}, }), ); - app.handler( - createHandler({ - name: "boom", - description: "throws", - handle: async () => { - throw new Error("connection timeout"); - }, - }), - ); - await app.route(["node", "myapp", "happy"]); - await app.route(["node", "myapp", "boom"]).catch(() => {}); await app.route(["node", "myapp", "happy"]); await assertLogsMatch(tempDir, [ { - filter: (l: any) => - l.msg === "command executed successfully" && l.commandPath === "/myapp/happy", - expectedCount: 2, - }, - { - filter: (l: any) => - l.level === "error" && - l.msg === "command failed" && - l.errorName === "Error" && - l.errorMessage === "connection timeout" && - l.commandPath === "/myapp/boom", + filter: (log: any) => + log.msg === "command executed successfully" && log.commandPath === "/myapp/happy", expectedCount: 1, }, ]); diff --git a/src/middleware/withLogging.tsx b/src/middleware/withLogging.tsx index ca7342da7..3e95e1983 100644 --- a/src/middleware/withLogging.tsx +++ b/src/middleware/withLogging.tsx @@ -25,7 +25,7 @@ function redactSensitiveFlags( /** * Middleware that creates a child logger bound to the current command path - * and logs execution start, success, and failure. + * and logs execution start and success. * * @param config - Contains the root {@link Logger} to derive children from. */ diff --git a/src/runnable/index.tsx b/src/runnable/index.tsx index c26097456..3ade152f3 100644 --- a/src/runnable/index.tsx +++ b/src/runnable/index.tsx @@ -1,4 +1,4 @@ -import { classify } from "../errors"; +import { AgentCoreCLIError } from "../errors"; // Runnable can be implemented by any application's main entrypoint. export interface Runnable { @@ -24,9 +24,9 @@ export async function runWithExitCode( await fn(argv); return 0; } catch (e) { - const error = classify(e); + const error = e instanceof Error ? e : new Error(String(e)); console.error(`${error.name}: ${error.message}`); - return error.exitCode; + return error instanceof AgentCoreCLIError ? error.exitCode : 1; } } From b25ac679bc1c49a7626967356cb1748ac560108b Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Sat, 25 Jul 2026 21:13:38 +0000 Subject: [PATCH 4/7] refactor(errors): move classification to src/index for future telemetry support --- src/errors/classify.test.tsx | 49 +++++++++++++++++++++++ src/errors/classify.tsx | 25 ++++++++++++ src/errors/index.ts | 76 +----------------------------------- src/errors/types.tsx | 52 ++++++++++++++++++++++++ src/runnable/index.tsx | 2 +- 5 files changed, 129 insertions(+), 75 deletions(-) create mode 100644 src/errors/classify.test.tsx create mode 100644 src/errors/classify.tsx create mode 100644 src/errors/types.tsx diff --git a/src/errors/classify.test.tsx b/src/errors/classify.test.tsx new file mode 100644 index 000000000..a29dcde85 --- /dev/null +++ b/src/errors/classify.test.tsx @@ -0,0 +1,49 @@ +import { describe, test, expect } from "bun:test"; +import { + AccessDeniedException, + ValidationException, + InternalServerException, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { classify } from "./classify"; +import { AgentCoreCLIError, InputValidationError, type ErrorSource } from "./types"; + +describe("classify", () => { + test("preserves existing AgentCoreCLIError instances", () => { + const err = new AgentCoreCLIError("cli error", { source: "internal" }); + expect(classify(err)).toBe(err); + }); + + test("preserves AgentCoreCLIError subclasses", () => { + const err = new InputValidationError("bad input"); + expect(classify(err)).toBe(err); + }); + + test.each<[Error, ErrorSource]>([ + [new AccessDeniedException({ $metadata: { httpStatusCode: 403 }, message: "" }), "user"], + [ + new ValidationException({ + $metadata: { httpStatusCode: 400 }, + message: "", + reason: "FieldValidationFailed", + }), + "user", + ], + [new InternalServerException({ $metadata: { httpStatusCode: 500 }, message: "" }), "service"], + [new InternalServerException({ $metadata: {}, message: "" }), "service"], + ])("SDK %s → %s source", (err, expectedSource) => { + const result = classify(err); + expect(result).toBeInstanceOf(AgentCoreCLIError); + expect(result.source).toBe(expectedSource); + }); + + test.each([ + [new Error("plain error"), "plain error"], + ["string error", "string error"], + [null, "null"], + [undefined, "undefined"], + ])("non-SDK error %j → unknown source", (input, expectedMessage) => { + const result = classify(input); + expect(result.source).toBe("unknown"); + expect(result.message).toBe(expectedMessage); + }); +}); diff --git a/src/errors/classify.tsx b/src/errors/classify.tsx new file mode 100644 index 000000000..46f6f42bc --- /dev/null +++ b/src/errors/classify.tsx @@ -0,0 +1,25 @@ +import { ServiceException } from "@smithy/core/client"; +import { AgentCoreCLIError, ERROR_SOURCE } from "./types"; + +/** Converts any thrown value into an {@link AgentCoreCLIError}, preserving known CLI errors. */ +export function classify(error: unknown): AgentCoreCLIError { + if (error instanceof AgentCoreCLIError) return error; + + if (ServiceException.isInstance(error)) { + const httpStatusCode = error.$metadata.httpStatusCode; + const source = + httpStatusCode !== undefined && httpStatusCode >= 400 && httpStatusCode < 500 + ? ERROR_SOURCE.USER + : ERROR_SOURCE.SERVICE; + + return new AgentCoreCLIError(error.message, { + cause: error, + source, + meta: { ...error.$metadata }, + }); + } + + if (error instanceof Error) return new AgentCoreCLIError(error.message, { cause: error }); + + return new AgentCoreCLIError(String(error), { cause: error }); +} diff --git a/src/errors/index.ts b/src/errors/index.ts index d4458ae30..76dfbf094 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -1,74 +1,2 @@ -import { ServiceException } from "@smithy/core/client"; - -export const ERROR_SOURCE = { - // note: this maps to the `client` error source in telemetry. - INTERNAL: "internal", - USER: "user", - SERVICE: "service", - UNKNOWN: "unknown", -} as const; - -export type ErrorSource = (typeof ERROR_SOURCE)[keyof typeof ERROR_SOURCE]; - -export interface AgentCoreCLIErrorOptions extends ErrorOptions { - source?: ErrorSource; - meta?: Record; - exitCode?: number; -} - -/** Base error for CLI failures, including their source, metadata, and process exit code. */ -export class AgentCoreCLIError extends Error { - readonly source: ErrorSource; - readonly meta: Record; - readonly exitCode: number; - - constructor(message?: string, options?: AgentCoreCLIErrorOptions) { - super(message, options); - this.name = new.target.name; - this.source = options?.source ?? ERROR_SOURCE.UNKNOWN; - this.meta = options?.meta ?? {}; - this.exitCode = options?.exitCode ?? 1; - } - /** Convert the error into an object with its attributes enumerated as keys **/ - json(): Record { - return { - name: this.name, - message: this.message, - stack: this.stack, - exitCode: this.exitCode, - meta: this.meta, - source: this.source, - }; - } -} - -/** Error raised for invalid user input. */ -export class InputValidationError extends AgentCoreCLIError { - constructor(message?: string, options?: Omit) { - super(message, { ...options, source: ERROR_SOURCE.USER }); - } -} - -/** Converts any thrown value into an {@link AgentCoreCLIError}, preserving known CLI errors. */ -export function classify(error: unknown): AgentCoreCLIError { - if (error instanceof AgentCoreCLIError) return error; - - if (ServiceException.isInstance(error)) { - const httpStatusCode = error.$metadata.httpStatusCode; - const source = - httpStatusCode !== undefined && httpStatusCode >= 400 && httpStatusCode < 500 - ? ERROR_SOURCE.USER - : ERROR_SOURCE.SERVICE; - - return new AgentCoreCLIError(error.message, { - cause: error, - source, - // note: we store the original name in meta so we can pull it out in telemetry. - meta: { ...error.$metadata, name: error.name }, - }); - } - - if (error instanceof Error) return new AgentCoreCLIError(error.message, { cause: error }); - - return new AgentCoreCLIError(String(error), { cause: error }); -} +export { classify } from "./classify"; +export { AgentCoreCLIError, InputValidationError } from "./types"; diff --git a/src/errors/types.tsx b/src/errors/types.tsx new file mode 100644 index 000000000..a323de6dc --- /dev/null +++ b/src/errors/types.tsx @@ -0,0 +1,52 @@ +export const ERROR_SOURCE = { + // note: this maps to the `client` error source in telemetry. + INTERNAL: "internal", + USER: "user", + SERVICE: "service", + UNKNOWN: "unknown", +} as const; + +/** Describes the source of the error, whether it was the user, internal to the CLI, a service, or unknown. */ +export type ErrorSource = (typeof ERROR_SOURCE)[keyof typeof ERROR_SOURCE]; + +export interface AgentCoreCLIErrorOptions extends ErrorOptions { + /** The source fo the error. See {@link ErrorSource} for more information */ + source?: ErrorSource; + /** Abitrary metdata to attach to errors for logging */ + meta?: Record; + /** Describes the exitCode for the CLI when this error hits the root handler */ + exitCode?: number; +} + +/** Base error for CLI failures, including their source, metadata, and process exit code. */ +export class AgentCoreCLIError extends Error { + readonly source: ErrorSource; + readonly meta: Record; + readonly exitCode: number; + + constructor(message?: string, options?: AgentCoreCLIErrorOptions) { + super(message, options); + this.name = new.target.name; + this.source = options?.source ?? ERROR_SOURCE.UNKNOWN; + this.meta = options?.meta ?? {}; + this.exitCode = options?.exitCode ?? 1; + } + /** Convert the error into an object with its attributes enumerated as keys **/ + json(): Record { + return { + name: this.name, + message: this.message, + stack: this.stack, + exitCode: this.exitCode, + meta: this.meta, + source: this.source, + }; + } +} + +/** Error raised for invalid user input. */ +export class InputValidationError extends AgentCoreCLIError { + constructor(message?: string, options?: Omit) { + super(message, { ...options, source: ERROR_SOURCE.USER }); + } +} diff --git a/src/runnable/index.tsx b/src/runnable/index.tsx index 3ade152f3..8f22aa371 100644 --- a/src/runnable/index.tsx +++ b/src/runnable/index.tsx @@ -25,7 +25,7 @@ export async function runWithExitCode( return 0; } catch (e) { const error = e instanceof Error ? e : new Error(String(e)); - console.error(`${error.name}: ${error.message}`); + console.error(`Error: ${error.message}`); return error instanceof AgentCoreCLIError ? error.exitCode : 1; } From 7d5dc82dfbfc3d6e61ccb67f08784b88f52ecaf4 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Mon, 27 Jul 2026 13:02:36 +0000 Subject: [PATCH 5/7] refactor(errors): reorganize filestructure --- src/errors/classify.test.tsx | 3 ++- src/errors/classify.tsx | 3 ++- src/errors/errors.tsx | 43 ++++++++++++++++++++++++++++++++++++ src/errors/index.ts | 2 -- src/errors/index.tsx | 2 ++ src/errors/types.tsx | 42 ----------------------------------- src/index.ts | 2 +- 7 files changed, 50 insertions(+), 47 deletions(-) create mode 100644 src/errors/errors.tsx delete mode 100644 src/errors/index.ts create mode 100644 src/errors/index.tsx diff --git a/src/errors/classify.test.tsx b/src/errors/classify.test.tsx index a29dcde85..f3935c079 100644 --- a/src/errors/classify.test.tsx +++ b/src/errors/classify.test.tsx @@ -5,7 +5,8 @@ import { InternalServerException, } from "@aws-sdk/client-bedrock-agentcore-control"; import { classify } from "./classify"; -import { AgentCoreCLIError, InputValidationError, type ErrorSource } from "./types"; +import { type ErrorSource } from "./types"; +import { AgentCoreCLIError, InputValidationError } from "./errors"; describe("classify", () => { test("preserves existing AgentCoreCLIError instances", () => { diff --git a/src/errors/classify.tsx b/src/errors/classify.tsx index 46f6f42bc..fe155b536 100644 --- a/src/errors/classify.tsx +++ b/src/errors/classify.tsx @@ -1,5 +1,6 @@ import { ServiceException } from "@smithy/core/client"; -import { AgentCoreCLIError, ERROR_SOURCE } from "./types"; +import { ERROR_SOURCE } from "./types"; +import { AgentCoreCLIError } from "./errors"; /** Converts any thrown value into an {@link AgentCoreCLIError}, preserving known CLI errors. */ export function classify(error: unknown): AgentCoreCLIError { diff --git a/src/errors/errors.tsx b/src/errors/errors.tsx new file mode 100644 index 000000000..c66b95fd2 --- /dev/null +++ b/src/errors/errors.tsx @@ -0,0 +1,43 @@ +import { ERROR_SOURCE, type ErrorSource } from "./types"; + +export interface AgentCoreCLIErrorOptions extends ErrorOptions { + /** The source fo the error. See {@link ErrorSource} for more information */ + source?: ErrorSource; + /** Abitrary metdata to attach to errors for logging */ + meta?: Record; + /** Describes the exitCode for the CLI when this error hits the root handler */ + exitCode?: number; +} + +/** Base error for CLI failures, including their source, metadata, and process exit code. */ +export class AgentCoreCLIError extends Error { + readonly source: ErrorSource; + readonly meta: Record; + readonly exitCode: number; + + constructor(message?: string, options?: AgentCoreCLIErrorOptions) { + super(message, options); + this.name = new.target.name; + this.source = options?.source ?? ERROR_SOURCE.UNKNOWN; + this.meta = options?.meta ?? {}; + this.exitCode = options?.exitCode ?? 1; + } + /** Convert the error into an object with its attributes enumerated as keys **/ + json(): Record { + return { + name: this.name, + message: this.message, + stack: this.stack, + exitCode: this.exitCode, + meta: this.meta, + source: this.source, + }; + } +} + +/** Error raised for invalid user input. */ +export class InputValidationError extends AgentCoreCLIError { + constructor(message?: string, options?: Omit) { + super(message, { ...options, source: ERROR_SOURCE.USER }); + } +} diff --git a/src/errors/index.ts b/src/errors/index.ts deleted file mode 100644 index 76dfbf094..000000000 --- a/src/errors/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { classify } from "./classify"; -export { AgentCoreCLIError, InputValidationError } from "./types"; diff --git a/src/errors/index.tsx b/src/errors/index.tsx new file mode 100644 index 000000000..997145a67 --- /dev/null +++ b/src/errors/index.tsx @@ -0,0 +1,2 @@ +export { classify } from "./classify"; +export { AgentCoreCLIError, InputValidationError } from "./errors"; diff --git a/src/errors/types.tsx b/src/errors/types.tsx index a323de6dc..2baff75e7 100644 --- a/src/errors/types.tsx +++ b/src/errors/types.tsx @@ -8,45 +8,3 @@ export const ERROR_SOURCE = { /** Describes the source of the error, whether it was the user, internal to the CLI, a service, or unknown. */ export type ErrorSource = (typeof ERROR_SOURCE)[keyof typeof ERROR_SOURCE]; - -export interface AgentCoreCLIErrorOptions extends ErrorOptions { - /** The source fo the error. See {@link ErrorSource} for more information */ - source?: ErrorSource; - /** Abitrary metdata to attach to errors for logging */ - meta?: Record; - /** Describes the exitCode for the CLI when this error hits the root handler */ - exitCode?: number; -} - -/** Base error for CLI failures, including their source, metadata, and process exit code. */ -export class AgentCoreCLIError extends Error { - readonly source: ErrorSource; - readonly meta: Record; - readonly exitCode: number; - - constructor(message?: string, options?: AgentCoreCLIErrorOptions) { - super(message, options); - this.name = new.target.name; - this.source = options?.source ?? ERROR_SOURCE.UNKNOWN; - this.meta = options?.meta ?? {}; - this.exitCode = options?.exitCode ?? 1; - } - /** Convert the error into an object with its attributes enumerated as keys **/ - json(): Record { - return { - name: this.name, - message: this.message, - stack: this.stack, - exitCode: this.exitCode, - meta: this.meta, - source: this.source, - }; - } -} - -/** Error raised for invalid user input. */ -export class InputValidationError extends AgentCoreCLIError { - constructor(message?: string, options?: Omit) { - super(message, { ...options, source: ERROR_SOURCE.USER }); - } -} diff --git a/src/index.ts b/src/index.ts index 4d301e35f..0e899ab04 100644 --- a/src/index.ts +++ b/src/index.ts @@ -55,7 +55,7 @@ process.exit( // Pass it to the root handler, along with the process's standard streams as // the app's io. CoreClient exposes feature sub-clients (e.g. `.harness`), so - // it satisfies the Core contract directly.u + // it satisfies the Core contract directly. const rootHandler = createRootHandler(coreClient, { io, logger: rootLogger, From dce99f77f60c0f6112727cf289aab5cb2a320d22 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Mon, 27 Jul 2026 13:59:38 +0000 Subject: [PATCH 6/7] fix(docs): address typo in docstring --- src/errors/errors.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/errors/errors.tsx b/src/errors/errors.tsx index c66b95fd2..65b10060c 100644 --- a/src/errors/errors.tsx +++ b/src/errors/errors.tsx @@ -3,7 +3,7 @@ import { ERROR_SOURCE, type ErrorSource } from "./types"; export interface AgentCoreCLIErrorOptions extends ErrorOptions { /** The source fo the error. See {@link ErrorSource} for more information */ source?: ErrorSource; - /** Abitrary metdata to attach to errors for logging */ + /** Arbitrary metdata to attach to errors for logging */ meta?: Record; /** Describes the exitCode for the CLI when this error hits the root handler */ exitCode?: number; From ddc8536f17037dc2a88f66c6c5fb2ca981e58c5b Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Mon, 27 Jul 2026 20:54:46 +0000 Subject: [PATCH 7/7] refactor(errors): move classify under the base class and remove unknown as a source --- src/errors/classify.test.tsx | 50 --------------------------- src/errors/classify.tsx | 26 -------------- src/errors/errors.test.tsx | 66 ++++++++++++++++++++++++++++++++++++ src/errors/errors.tsx | 25 +++++++++++++- src/errors/index.tsx | 1 - src/errors/types.tsx | 1 - src/index.ts | 4 +-- 7 files changed, 92 insertions(+), 81 deletions(-) delete mode 100644 src/errors/classify.test.tsx delete mode 100644 src/errors/classify.tsx create mode 100644 src/errors/errors.test.tsx diff --git a/src/errors/classify.test.tsx b/src/errors/classify.test.tsx deleted file mode 100644 index f3935c079..000000000 --- a/src/errors/classify.test.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, test, expect } from "bun:test"; -import { - AccessDeniedException, - ValidationException, - InternalServerException, -} from "@aws-sdk/client-bedrock-agentcore-control"; -import { classify } from "./classify"; -import { type ErrorSource } from "./types"; -import { AgentCoreCLIError, InputValidationError } from "./errors"; - -describe("classify", () => { - test("preserves existing AgentCoreCLIError instances", () => { - const err = new AgentCoreCLIError("cli error", { source: "internal" }); - expect(classify(err)).toBe(err); - }); - - test("preserves AgentCoreCLIError subclasses", () => { - const err = new InputValidationError("bad input"); - expect(classify(err)).toBe(err); - }); - - test.each<[Error, ErrorSource]>([ - [new AccessDeniedException({ $metadata: { httpStatusCode: 403 }, message: "" }), "user"], - [ - new ValidationException({ - $metadata: { httpStatusCode: 400 }, - message: "", - reason: "FieldValidationFailed", - }), - "user", - ], - [new InternalServerException({ $metadata: { httpStatusCode: 500 }, message: "" }), "service"], - [new InternalServerException({ $metadata: {}, message: "" }), "service"], - ])("SDK %s → %s source", (err, expectedSource) => { - const result = classify(err); - expect(result).toBeInstanceOf(AgentCoreCLIError); - expect(result.source).toBe(expectedSource); - }); - - test.each([ - [new Error("plain error"), "plain error"], - ["string error", "string error"], - [null, "null"], - [undefined, "undefined"], - ])("non-SDK error %j → unknown source", (input, expectedMessage) => { - const result = classify(input); - expect(result.source).toBe("unknown"); - expect(result.message).toBe(expectedMessage); - }); -}); diff --git a/src/errors/classify.tsx b/src/errors/classify.tsx deleted file mode 100644 index fe155b536..000000000 --- a/src/errors/classify.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { ServiceException } from "@smithy/core/client"; -import { ERROR_SOURCE } from "./types"; -import { AgentCoreCLIError } from "./errors"; - -/** Converts any thrown value into an {@link AgentCoreCLIError}, preserving known CLI errors. */ -export function classify(error: unknown): AgentCoreCLIError { - if (error instanceof AgentCoreCLIError) return error; - - if (ServiceException.isInstance(error)) { - const httpStatusCode = error.$metadata.httpStatusCode; - const source = - httpStatusCode !== undefined && httpStatusCode >= 400 && httpStatusCode < 500 - ? ERROR_SOURCE.USER - : ERROR_SOURCE.SERVICE; - - return new AgentCoreCLIError(error.message, { - cause: error, - source, - meta: { ...error.$metadata }, - }); - } - - if (error instanceof Error) return new AgentCoreCLIError(error.message, { cause: error }); - - return new AgentCoreCLIError(String(error), { cause: error }); -} diff --git a/src/errors/errors.test.tsx b/src/errors/errors.test.tsx new file mode 100644 index 000000000..168d02e4e --- /dev/null +++ b/src/errors/errors.test.tsx @@ -0,0 +1,66 @@ +import { describe, test, expect } from "bun:test"; +import { + AccessDeniedException, + ValidationException, + InternalServerException, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { AgentCoreCLIError, InputValidationError } from "./errors"; + +describe("AgentCoreCLIError", () => { + test("fromError preserves existing AgentCoreCLIError instances", () => { + const err = new AgentCoreCLIError("cli error", { source: "internal" }); + expect(AgentCoreCLIError.fromError(err)).toBe(err); + }); + + test("fromError preserves AgentCoreCLIError subclasses", () => { + const err = new InputValidationError("bad input"); + expect(AgentCoreCLIError.fromError(err)).toBe(err); + }); + + test.each([ + [ + "AccessDeniedException (403)", + new AccessDeniedException({ $metadata: { httpStatusCode: 403 }, message: "" }), + "user", + ], + [ + "ValidationException (400)", + new ValidationException({ + $metadata: { httpStatusCode: 400 }, + message: "", + reason: "FieldValidationFailed", + }), + "user", + ], + [ + "InternalServerException (500)", + new InternalServerException({ $metadata: { httpStatusCode: 500 }, message: "" }), + "service", + ], + [ + "InternalServerException (no status)", + new InternalServerException({ $metadata: {}, message: "" }), + "service", + ], + ])("fromError SDK %s → expected source", (_label, err, expectedSource) => { + const result = AgentCoreCLIError.fromError(err as Error); + expect(result.json()).toMatchObject({ + name: "AgentCoreCLIError", + source: expectedSource, + }); + }); + + test.each([ + ["Error", new Error("plain error"), "plain error"], + ["string", "string error", "string error"], + ["null", null, "null"], + ["undefined", undefined, "undefined"], + ])("fromError non-SDK %s → internal source", (_label, input, expectedMessage) => { + const result = AgentCoreCLIError.fromError(input); + expect(result.json()).toMatchObject({ + name: "AgentCoreCLIError", + source: "internal", + message: expectedMessage, + }); + }); +}); diff --git a/src/errors/errors.tsx b/src/errors/errors.tsx index 65b10060c..86051d52f 100644 --- a/src/errors/errors.tsx +++ b/src/errors/errors.tsx @@ -1,3 +1,4 @@ +import { ServiceException } from "@smithy/core/client"; import { ERROR_SOURCE, type ErrorSource } from "./types"; export interface AgentCoreCLIErrorOptions extends ErrorOptions { @@ -18,7 +19,7 @@ export class AgentCoreCLIError extends Error { constructor(message?: string, options?: AgentCoreCLIErrorOptions) { super(message, options); this.name = new.target.name; - this.source = options?.source ?? ERROR_SOURCE.UNKNOWN; + this.source = options?.source ?? ERROR_SOURCE.INTERNAL; this.meta = options?.meta ?? {}; this.exitCode = options?.exitCode ?? 1; } @@ -33,6 +34,28 @@ export class AgentCoreCLIError extends Error { source: this.source, }; } + + static fromError(error: unknown): AgentCoreCLIError { + if (error instanceof AgentCoreCLIError) return error; + + if (ServiceException.isInstance(error)) { + const httpStatusCode = error.$metadata.httpStatusCode; + const source = + httpStatusCode !== undefined && httpStatusCode >= 400 && httpStatusCode < 500 + ? ERROR_SOURCE.USER + : ERROR_SOURCE.SERVICE; + + return new AgentCoreCLIError(error.message, { + cause: error, + source, + meta: { ...error.$metadata }, + }); + } + + if (error instanceof Error) return new AgentCoreCLIError(error.message, { cause: error }); + + return new AgentCoreCLIError(String(error), { cause: error }); + } } /** Error raised for invalid user input. */ diff --git a/src/errors/index.tsx b/src/errors/index.tsx index 997145a67..5f96f8766 100644 --- a/src/errors/index.tsx +++ b/src/errors/index.tsx @@ -1,2 +1 @@ -export { classify } from "./classify"; export { AgentCoreCLIError, InputValidationError } from "./errors"; diff --git a/src/errors/types.tsx b/src/errors/types.tsx index 2baff75e7..f8fef1509 100644 --- a/src/errors/types.tsx +++ b/src/errors/types.tsx @@ -3,7 +3,6 @@ export const ERROR_SOURCE = { INTERNAL: "internal", USER: "user", SERVICE: "service", - UNKNOWN: "unknown", } as const; /** Describes the source of the error, whether it was the user, internal to the CLI, a service, or unknown. */ diff --git a/src/index.ts b/src/index.ts index 6fdf60e2a..b36ac6789 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,7 @@ import { FsReadWriteJson } from "./io"; import { createFileLogger, LOG_LEVEL } from "./logging"; import { runWithExitCode } from "./runnable"; import { DefaultGlobalConfigAccessor } from "./globalConfig"; -import { classify } from "./errors"; +import { AgentCoreCLIError } from "./errors"; process.exit( await runWithExitCode(async (argv: string[]) => { @@ -66,7 +66,7 @@ process.exit( // Handle the request await rootHandler.route(argv); } catch (e) { - const error = classify(e); + const error = AgentCoreCLIError.fromError(e); rootLogger.child({ error: error.json() }).error(); throw error; } finally {