From 4d3a0996ad51f2b0ede8b93b9bfef75c2b3f3652 Mon Sep 17 00:00:00 2001 From: Aliyu Habibu Date: Mon, 1 Jun 2026 23:58:12 +0100 Subject: [PATCH] feat: standardize SDK error taxonomy across backend and SDK === Core error taxonomy (packages/sdk/src/errors.ts) === New ErrorCategory enum with 7 top-level categories: TRANSPORT, VALIDATION, SIMULATION, POLICY, COMPATIBILITY, EXECUTION, UNKNOWN SdkError base class with category, code, message, recoverable, details, cause, and toJSON(). Factory functions for every category: transportError, connectionTimeoutError, networkError, validationError, invalidInputError, missingFieldError, simulationError, policyError, rateLimitError, unauthorizedError, compatibilityError, unsupportedChainError, unsupportedOperationError, executionError, signingError, insufficientFundsError === SDK alignment === - AgentRequestError extends SdkError with automatic category from HTTP status - AgentClient: categorized error parsing from API responses, CategorizedError type - SignatureProviderError: each error code maps to an ErrorCategory - types/index.ts: new CategorizedError interface for API error responses - index.ts: exports new errors module === Backend alignment === - ToolResult: new errorCategory and errorCode fields - BaseTool.createErrorResult: accepts optional errorCategory and errorCode - ApplicationError: gains errorCategory (auto-derived from statusCode) and errorCode, plus toCategorizedErrorResponse() helper - ErrorHandler middleware: response includes category and recoverable fields - SorobanError: maps every SorobanErrorCode to an ErrorCategory via sorobanCodeToCategory() and getSorobanCategory() helpers Clients can now switch on error.category without parsing strings: if (err.category === 'POLICY') handleRateLimit() --- packages/sdk/src/agentClient.ts | 108 +++++-- packages/sdk/src/errors.ts | 273 ++++++++++++++++++ packages/sdk/src/index.ts | 1 + .../sdk/src/signature-providers/errors.ts | 38 +++ packages/sdk/src/types/index.ts | 9 + src/Agents/registry/ToolMetadata.ts | 4 + src/Agents/tools/base/BaseTool.ts | 8 +- src/Gateway/middleware/errorHandler.ts | 69 +++-- src/services/soroban/errors.ts | 58 ++++ src/utils/error.ts | 44 ++- 10 files changed, 567 insertions(+), 45 deletions(-) create mode 100644 packages/sdk/src/errors.ts diff --git a/packages/sdk/src/agentClient.ts b/packages/sdk/src/agentClient.ts index 0e56f74c..62af2a08 100644 --- a/packages/sdk/src/agentClient.ts +++ b/packages/sdk/src/agentClient.ts @@ -1,5 +1,6 @@ import { createHash, randomUUID } from "crypto"; -import { AgentResponse, ChainId, CrossChainSwapRequest } from "./types"; +import { AgentResponse, ChainId, CrossChainSwapRequest, CategorizedError } from "./types"; +import { SdkError, ErrorCategory, transportError } from "./errors"; /** Input required to generate a stable idempotency key */ export interface IdempotencyKeyInput { @@ -60,7 +61,7 @@ export interface ExecuteBtcToStellarSwapOptions { } /** Error thrown when an agent request fails after all retries */ -export class AgentRequestError extends Error { +export class AgentRequestError extends SdkError { readonly idempotencyKey: string; readonly attempts: number; readonly statusCode?: number; @@ -69,9 +70,19 @@ export class AgentRequestError extends Error { message: string, idempotencyKey: string, attempts: number, - statusCode?: number + statusCode?: number, ) { - super(message); + const category = statusCode !== undefined + ? categorizeHttpStatus(statusCode) + : ErrorCategory.TRANSPORT; + const code = statusCode !== undefined + ? `HTTP_${statusCode}` + : "AGENT_REQUEST_FAILED"; + const recoverable = statusCode !== undefined + ? RETRIABLE_STATUS_CODES.has(statusCode) + : false; + + super({ category, code, message, recoverable }); this.name = "AgentRequestError"; this.idempotencyKey = idempotencyKey; this.attempts = attempts; @@ -102,6 +113,14 @@ type FetchLike = ( const RETRIABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]); +function categorizeHttpStatus(status: number): ErrorCategory { + if (status === 429) return ErrorCategory.POLICY; + if (status === 422 || status === 400) return ErrorCategory.VALIDATION; + if (status === 401 || status === 403) return ErrorCategory.POLICY; + if (status >= 500) return ErrorCategory.EXECUTION; + return ErrorCategory.TRANSPORT; +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -194,6 +213,32 @@ function createTimedSignal( }; } +/** + * Parse a categorized error from a JSON response body. + */ +function parseCategorizedError(body: string, status: number): CategorizedError { + try { + const parsed = JSON.parse(body); + if (parsed.error && parsed.error.category) { + return { + category: parsed.error.category, + code: parsed.error.code ?? `HTTP_${status}`, + message: parsed.error.message ?? body, + recoverable: parsed.error.recoverable ?? false, + details: parsed.error.details, + }; + } + } catch { + // not JSON, fall through + } + return { + category: categorizeHttpStatus(status), + code: `HTTP_${status}`, + message: body, + recoverable: RETRIABLE_STATUS_CODES.has(status), + }; +} + /** * Client for interacting with the Chen Pilot AI Agent backend. * Provides resilient querying with retries and timeout controls. @@ -244,7 +289,12 @@ export class AgentClient { const retryDelayMs = request.retryDelayMs ?? this.defaultRetryDelayMs; let attempts = 0; - let lastErrorMessage = "Request failed"; + let lastCategorizedError: CategorizedError = { + category: "TRANSPORT", + code: "UNKNOWN", + message: "Request failed", + recoverable: false, + }; let lastStatusCode: number | undefined; while (attempts < maxRetries) { @@ -268,21 +318,20 @@ export class AgentClient { if (!response.ok) { lastStatusCode = response.status; const body = await response.text().catch(() => ""); - const message = body || `HTTP ${response.status}`; + lastCategorizedError = parseCategorizedError(body, response.status); if ( !RETRIABLE_STATUS_CODES.has(response.status) || attempts >= maxRetries ) { throw new AgentRequestError( - `Agent query failed: ${message}`, + `Agent query failed: ${lastCategorizedError.message}`, idempotencyKey, attempts, - response.status + response.status, ); } - lastErrorMessage = `Agent query failed: ${message}`; await sleep(retryDelayMs * attempts); continue; } @@ -294,6 +343,10 @@ export class AgentClient { result: parsed.result, }; } catch (error) { + if (error instanceof AgentRequestError) { + throw error; + } + const isAbort = error instanceof Error && (error.name === "AbortError" || error.message.includes("aborted")); @@ -302,19 +355,23 @@ export class AgentClient { (error instanceof Error && error.message.toLowerCase().includes("network")); - if (error instanceof AgentRequestError) { - throw error; - } - - lastErrorMessage = - error instanceof Error ? error.message : String(error); + lastCategorizedError = { + category: isAbort + ? "TRANSPORT" + : isNetwork + ? "TRANSPORT" + : "UNKNOWN", + code: isAbort ? "REQUEST_ABORTED" : isNetwork ? "NETWORK_ERROR" : "UNKNOWN", + message: error instanceof Error ? error.message : String(error), + recoverable: isNetwork || isAbort, + }; if (!(isAbort || isNetwork) || attempts >= maxRetries) { throw new AgentRequestError( - `Agent query failed: ${lastErrorMessage}`, + `Agent query failed: ${lastCategorizedError.message}`, idempotencyKey, attempts, - lastStatusCode + lastStatusCode, ); } @@ -325,10 +382,10 @@ export class AgentClient { } throw new AgentRequestError( - `Agent query failed: ${lastErrorMessage}`, + `Agent query failed: ${lastCategorizedError.message}`, idempotencyKey, attempts, - lastStatusCode + lastStatusCode, ); } @@ -347,9 +404,16 @@ export class AgentClient { swapRequest.fromChain !== ChainId.BITCOIN || swapRequest.toChain !== ChainId.STELLAR ) { - throw new Error( - "executeBtcToStellarSwap only supports fromChain=bitcoin and toChain=stellar" - ); + throw new SdkError({ + category: ErrorCategory.VALIDATION, + code: "INVALID_SWAP_DIRECTION", + message: + "executeBtcToStellarSwap only supports fromChain=bitcoin and toChain=stellar", + details: { + fromChain: swapRequest.fromChain, + toChain: swapRequest.toChain, + }, + }); } const idempotencyKey = diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts new file mode 100644 index 00000000..eab7db11 --- /dev/null +++ b/packages/sdk/src/errors.ts @@ -0,0 +1,273 @@ +/** + * Standardized error taxonomy for the Chen Pilot SDK. + * + * Every error carries: + * - category: one of six top-level categories clients can switch on + * - code: machine-readable error code (no string parsing needed) + * - message: human-readable description + * - recoverable: whether the caller can safely retry the operation + * - details: optional structured metadata + */ + +// ─── Error categories ───────────────────────────────────────────────────────── + +export enum ErrorCategory { + /** Network / connection / DNS / timeout failures */ + TRANSPORT = "TRANSPORT", + /** Input schema, type, or constraint violations */ + VALIDATION = "VALIDATION", + /** Contract or off-chain simulation failures */ + SIMULATION = "SIMULATION", + /** Rate limits, permissions, KYC, governance rules */ + POLICY = "POLICY", + /** Chain or contract incompatibility */ + COMPATIBILITY = "COMPATIBILITY", + /** Transaction submission, signing, or runtime failures */ + EXECUTION = "EXECUTION", + /** Catch-all for errors that don't fit above */ + UNKNOWN = "UNKNOWN", +} + +// ─── Error specification ────────────────────────────────────────────────────── + +export interface SdkErrorSpec { + category: ErrorCategory; + code: string; + message: string; + recoverable?: boolean; + details?: Record; + cause?: unknown; +} + +// ─── Base error class ───────────────────────────────────────────────────────── + +export class SdkError extends Error { + public readonly category: ErrorCategory; + public readonly code: string; + public readonly recoverable: boolean; + public readonly details?: Record; + public readonly cause?: unknown; + + constructor(spec: SdkErrorSpec) { + super(spec.message); + this.name = "SdkError"; + this.category = spec.category; + this.code = spec.code; + this.recoverable = spec.recoverable ?? false; + this.details = spec.details; + this.cause = spec.cause; + } + + toJSON(): Record { + return { + name: this.name, + category: this.category, + code: this.code, + message: this.message, + recoverable: this.recoverable, + details: this.details, + }; + } +} + +// ─── Helpers for checking error properties ──────────────────────────────────── + +export function isSdkError(error: unknown): error is SdkError { + return error instanceof SdkError; +} + +export function getErrorCategory(error: unknown): ErrorCategory { + if (error instanceof SdkError) return error.category; + if (error instanceof TypeError) return ErrorCategory.TRANSPORT; + return ErrorCategory.UNKNOWN; +} + +export function isRecoverableError(error: unknown): boolean { + if (error instanceof SdkError) return error.recoverable; + return false; +} + +// ─── Standard transport errors ──────────────────────────────────────────────── + +export function transportError( + message: string, + details?: Record, +): SdkError { + return new SdkError({ + category: ErrorCategory.TRANSPORT, + code: "TRANSPORT_ERROR", + message, + recoverable: true, + details, + }); +} + +export function connectionTimeoutError( + message = "Connection timed out", +): SdkError { + return new SdkError({ + category: ErrorCategory.TRANSPORT, + code: "CONNECTION_TIMEOUT", + message, + recoverable: true, + }); +} + +export function networkError(message = "Network error"): SdkError { + return new SdkError({ + category: ErrorCategory.TRANSPORT, + code: "NETWORK_ERROR", + message, + recoverable: true, + }); +} + +// ─── Standard validation errors ─────────────────────────────────────────────── + +export function validationError( + message: string, + details?: Record, +): SdkError { + return new SdkError({ + category: ErrorCategory.VALIDATION, + code: "VALIDATION_ERROR", + message, + details, + }); +} + +export function invalidInputError( + field: string, + expected: string, + received?: string, +): SdkError { + return new SdkError({ + category: ErrorCategory.VALIDATION, + code: "INVALID_INPUT", + message: `Invalid input for '${field}': expected ${expected}${ + received !== undefined ? `, got ${received}` : "" + }`, + details: { field, expected, received }, + }); +} + +export function missingFieldError(field: string): SdkError { + return new SdkError({ + category: ErrorCategory.VALIDATION, + code: "MISSING_FIELD", + message: `Missing required field: '${field}'`, + details: { field }, + }); +} + +// ─── Standard simulation errors ─────────────────────────────────────────────── + +export function simulationError( + message: string, + details?: Record, +): SdkError { + return new SdkError({ + category: ErrorCategory.SIMULATION, + code: "SIMULATION_ERROR", + message, + details, + }); +} + +// ─── Standard policy errors ─────────────────────────────────────────────────── + +export function policyError( + message: string, + details?: Record, +): SdkError { + return new SdkError({ + category: ErrorCategory.POLICY, + code: "POLICY_VIOLATION", + message, + details, + }); +} + +export function rateLimitError(retryAfterMs?: number): SdkError { + return new SdkError({ + category: ErrorCategory.POLICY, + code: "RATE_LIMITED", + message: retryAfterMs + ? `Rate limited. Retry after ${retryAfterMs}ms` + : "Rate limited", + recoverable: true, + details: retryAfterMs !== undefined ? { retryAfterMs } : undefined, + }); +} + +export function unauthorizedError(message = "Unauthorized"): SdkError { + return new SdkError({ + category: ErrorCategory.POLICY, + code: "UNAUTHORIZED", + message, + }); +} + +// ─── Standard compatibility errors ──────────────────────────────────────────── + +export function compatibilityError( + message: string, + details?: Record, +): SdkError { + return new SdkError({ + category: ErrorCategory.COMPATIBILITY, + code: "COMPATIBILITY_ERROR", + message, + details, + }); +} + +export function unsupportedChainError(chainId: string): SdkError { + return new SdkError({ + category: ErrorCategory.COMPATIBILITY, + code: "UNSUPPORTED_CHAIN", + message: `Unsupported chain: ${chainId}`, + details: { chainId }, + }); +} + +export function unsupportedOperationError(operation: string): SdkError { + return new SdkError({ + category: ErrorCategory.COMPATIBILITY, + code: "UNSUPPORTED_OPERATION", + message: `Unsupported operation: ${operation}`, + details: { operation }, + }); +} + +// ─── Standard execution errors ──────────────────────────────────────────────── + +export function executionError( + message: string, + details?: Record, +): SdkError { + return new SdkError({ + category: ErrorCategory.EXECUTION, + code: "EXECUTION_ERROR", + message, + details, + }); +} + +export function signingError(message = "Signing failed"): SdkError { + return new SdkError({ + category: ErrorCategory.EXECUTION, + code: "SIGNING_ERROR", + message, + }); +} + +export function insufficientFundsError( + message = "Insufficient funds", +): SdkError { + return new SdkError({ + category: ErrorCategory.EXECUTION, + code: "INSUFFICIENT_FUNDS", + message, + }); +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 3fdd3688..5889df45 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,3 +1,4 @@ +export * from "./errors"; export * from "./types"; export * from "./recovery"; export * from "./planVerification"; diff --git a/packages/sdk/src/signature-providers/errors.ts b/packages/sdk/src/signature-providers/errors.ts index d95ceb1e..f5a654d7 100644 --- a/packages/sdk/src/signature-providers/errors.ts +++ b/packages/sdk/src/signature-providers/errors.ts @@ -1,10 +1,47 @@ import { ChainId } from "../types"; +import { ErrorCategory } from "../errors"; + +/** + * Maps a SignatureProvider error code to its ErrorCategory. + */ +function codeToCategory(code: string): ErrorCategory { + switch (code) { + case "CONNECTION_ERROR": + case "CONNECTION_TIMEOUT": + case "PROVIDER_NOT_FOUND": + case "NETWORK_ERROR": + return ErrorCategory.TRANSPORT; + case "INVALID_TRANSACTION": + case "INVALID_PROVIDER_IMPLEMENTATION": + case "INVALID_PROVIDER_ID": + case "INVALID_PROVIDER_METADATA": + case "UNSUPPORTED_PROVIDER_TYPE": + return ErrorCategory.VALIDATION; + case "SIGNING_ERROR": + case "INSUFFICIENT_FUNDS": + case "HARDWARE_WALLET_ERROR": + case "DEVICE_NOT_FOUND": + case "DEVICE_BUSY": + case "DEVICE_LOCKED": + return ErrorCategory.EXECUTION; + case "AUTHENTICATION_ERROR": + case "USER_REJECTED": + case "UNAUTHORIZED": + return ErrorCategory.POLICY; + case "UNSUPPORTED_CHAIN": + case "UNSUPPORTED_OPERATION": + return ErrorCategory.COMPATIBILITY; + default: + return ErrorCategory.UNKNOWN; + } +} /** * Base error class for all SignatureProvider related errors */ export abstract class SignatureProviderError extends Error { public readonly code: string; + public readonly category: ErrorCategory; public readonly providerId?: string; public readonly chainId?: ChainId; public readonly recoverable: boolean; @@ -19,6 +56,7 @@ export abstract class SignatureProviderError extends Error { super(message); this.name = this.constructor.name; this.code = code; + this.category = codeToCategory(code); this.providerId = providerId; this.chainId = chainId; this.recoverable = recoverable; diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts index 269fb06c..b175dd1f 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -30,6 +30,15 @@ export interface AgentResponse { data?: unknown; } +/** Structured error returned by the backend for categorized failures */ +export interface CategorizedError { + category: string; + code: string; + message: string; + recoverable: boolean; + details?: Record; +} + /** Recovery and cleanup actions available during cross-chain flows */ export enum RecoveryAction { RETRY_MINT = "retry_mint", diff --git a/src/Agents/registry/ToolMetadata.ts b/src/Agents/registry/ToolMetadata.ts index f5b25a74..c4966717 100644 --- a/src/Agents/registry/ToolMetadata.ts +++ b/src/Agents/registry/ToolMetadata.ts @@ -36,6 +36,10 @@ export interface ToolResult { message?: string; data?: Record; error?: string; + /** Machine-readable error category (TRANSPORT, VALIDATION, SIMULATION, POLICY, COMPATIBILITY, EXECUTION, UNKNOWN) */ + errorCategory?: string; + /** Machine-readable error code for the specific failure */ + errorCode?: string; } export interface ToolExecutionError extends Error { diff --git a/src/Agents/tools/base/BaseTool.ts b/src/Agents/tools/base/BaseTool.ts index 29160a8f..b5b7acdc 100644 --- a/src/Agents/tools/base/BaseTool.ts +++ b/src/Agents/tools/base/BaseTool.ts @@ -84,18 +84,22 @@ export abstract class BaseTool } /** - * error result + * error result with optional error category and code */ protected createErrorResult( action: string, error: string, - data: Record = {} + data: Record = {}, + errorCategory?: string, + errorCode?: string, ): ToolResult { return { action, status: "error", error, data, + errorCategory, + errorCode, }; } } diff --git a/src/Gateway/middleware/errorHandler.ts b/src/Gateway/middleware/errorHandler.ts index 4d584686..5797bad8 100644 --- a/src/Gateway/middleware/errorHandler.ts +++ b/src/Gateway/middleware/errorHandler.ts @@ -1,9 +1,13 @@ import { Request, Response, NextFunction } from "express"; import logger from "../../config/logger"; -import { ApplicationError } from "../../utils/error"; +import { + ApplicationError, + toCategorizedErrorResponse, + ErrorCategory, +} from "../../utils/error"; /** - * Interface for the standardized error response + * Interface for the standardized error response with taxonomy fields */ interface StandardErrorResponse { success: boolean; @@ -11,11 +15,32 @@ interface StandardErrorResponse { error: { message: string; code: string; + category: string; + recoverable: boolean; details?: unknown; stack?: string; }; } +/** + * Derive an ErrorCategory from a raw DB error code. + */ +function dbCodeToCategory(dbCode: string): ErrorCategory { + switch (dbCode) { + case "23502": + case "23503": + case "22001": + case "22007": + case "22P02": + case "23514": + return "VALIDATION"; + case "23505": + return "POLICY"; + default: + return "UNKNOWN"; + } +} + /** * Centralized error handling middleware */ @@ -35,57 +60,60 @@ export async function ErrorHandler( let statusCode = 500; let message = "Internal server error"; let errorCode = "INTERNAL_SERVER_ERROR"; + let category: ErrorCategory = "UNKNOWN"; + let recoverable = false; const details: unknown = undefined; - // Handle custom ApplicationErrors if (error instanceof ApplicationError) { - statusCode = error.statusCode; - message = error.message; - errorCode = - error.constructor.name.replace(/Error$/, "").toUpperCase() + "_ERROR"; - } - // Handle database (TypeORM) errors - else if (error.code) { + const categorized = toCategorizedErrorResponse(error); + statusCode = categorized.statusCode; + message = categorized.message; + errorCode = categorized.code; + category = categorized.category as ErrorCategory; + recoverable = statusCode >= 500 || statusCode === 429; + } else if (error.code) { + category = dbCodeToCategory(error.code); + recoverable = false; + switch (error.code) { - case "23502": // Not null violation + case "23502": message = error.column ? `Field '${error.column}' cannot be empty.` : "A required field is missing."; statusCode = 400; errorCode = "MISSING_FIELD"; break; - case "23505": // Unique violation + case "23505": message = "Duplicate entry. This record already exists."; statusCode = 409; errorCode = "DUPLICATE_ENTRY"; break; - case "23503": // Foreign key violation + case "23503": message = "Invalid reference. The related record does not exist."; statusCode = 400; errorCode = "INVALID_REFERENCE"; break; - case "22001": // Value too long + case "22001": message = "Data is too long for the specified field."; statusCode = 400; errorCode = "VALUE_TOO_LONG"; break; - case "22007": // Invalid datetime format + case "22007": message = "Invalid date/time format."; statusCode = 400; errorCode = "INVALID_DATE_FORMAT"; break; - case "22P02": // Invalid text representation + case "22P02": message = "Invalid input format."; statusCode = 400; errorCode = "INVALID_INPUT_FORMAT"; break; - case "23514": // Check violation + case "23514": message = "Field value does not meet required constraints."; statusCode = 400; errorCode = "CONSTRAINT_VIOLATION"; break; default: - // Use pre-existing message if it's from a library we trust if (error.message) message = error.message; break; } @@ -93,11 +121,11 @@ export async function ErrorHandler( message = error.message; } - // Log the error with context logger.error("Request error", { message: error.message || "No message provided", statusCode, errorCode, + category, method: req.method, url: req.originalUrl, stack: error.stack, @@ -109,8 +137,9 @@ export async function ErrorHandler( error: { message, code: errorCode, + category, + recoverable, details, - // Only include stack trace in development stack: process.env.NODE_ENV === "development" ? error.stack : undefined, }, }; diff --git a/src/services/soroban/errors.ts b/src/services/soroban/errors.ts index ed0dfde5..86322480 100644 --- a/src/services/soroban/errors.ts +++ b/src/services/soroban/errors.ts @@ -16,14 +16,72 @@ export type SorobanErrorCode = | "TTL_EXTENSION_FAILED" | "UNKNOWN"; +/** + * Top-level error categories shared with the SDK taxonomy. + */ +export type ErrorCategory = + | "TRANSPORT" + | "VALIDATION" + | "SIMULATION" + | "POLICY" + | "COMPATIBILITY" + | "EXECUTION" + | "UNKNOWN"; + +/** + * Maps a SorobanErrorCode to its top-level ErrorCategory. + */ +export function sorobanCodeToCategory(code: SorobanErrorCode): ErrorCategory { + switch (code) { + case "INVALID_PARAMS": + return "VALIDATION"; + case "SDK_INIT_FAILED": + return "COMPATIBILITY"; + case "SIMULATION_FAILED": + case "SIMULATION_ERROR_RESPONSE": + return "SIMULATION"; + case "AUTH_REQUIRED": + return "POLICY"; + case "DECODE_FAILED": + return "VALIDATION"; + case "SIGNING_FAILED": + return "EXECUTION"; + case "INVOCATION_FAILED": + return "EXECUTION"; + case "TTL_EXTENSION_FAILED": + return "EXECUTION"; + case "UNKNOWN": + return "UNKNOWN"; + } +} + +const categoryByCode: Record = { + INVALID_PARAMS: "VALIDATION", + SDK_INIT_FAILED: "COMPATIBILITY", + SIMULATION_FAILED: "SIMULATION", + SIMULATION_ERROR_RESPONSE: "SIMULATION", + AUTH_REQUIRED: "POLICY", + DECODE_FAILED: "VALIDATION", + SIGNING_FAILED: "EXECUTION", + INVOCATION_FAILED: "EXECUTION", + TTL_EXTENSION_FAILED: "EXECUTION", + UNKNOWN: "UNKNOWN", +}; + +export function getSorobanCategory(code: SorobanErrorCode): ErrorCategory { + return categoryByCode[code] ?? "UNKNOWN"; +} + export class SorobanError extends Error { readonly code: SorobanErrorCode; + readonly errorCategory: ErrorCategory; readonly cause?: unknown; constructor(message: string, code: SorobanErrorCode, cause?: unknown) { super(message); this.name = "SorobanError"; this.code = code; + this.errorCategory = categoryByCode[code] ?? "UNKNOWN"; this.cause = cause; } } diff --git a/src/utils/error.ts b/src/utils/error.ts index 842fe09d..d450b992 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -1,17 +1,59 @@ import express, { NextFunction } from "express"; +export type ErrorCategory = + | "TRANSPORT" + | "VALIDATION" + | "SIMULATION" + | "POLICY" + | "COMPATIBILITY" + | "EXECUTION" + | "UNKNOWN"; + +/** + * Map an HTTP status code to an ErrorCategory. + */ +export function httpStatusToCategory(status: number): ErrorCategory { + if (status === 429) return "POLICY"; + if (status === 422 || status === 400) return "VALIDATION"; + if (status === 401 || status === 403) return "POLICY"; + if (status === 404) return "VALIDATION"; + if (status >= 500) return "EXECUTION"; + return "UNKNOWN"; +} + export class ApplicationError extends Error { statusCode: number; message: string; + errorCategory: ErrorCategory; + errorCode?: string; - constructor(message: string, statusCode: number) { + constructor(message: string, statusCode: number, errorCode?: string) { super(message); this.statusCode = statusCode; this.message = message; + this.errorCategory = httpStatusToCategory(statusCode); + this.errorCode = errorCode; Error.captureStackTrace(this, this.constructor); } } +export function toCategorizedErrorResponse(err: ApplicationError): { + message: string; + code: string; + category: string; + statusCode: number; +} { + return { + message: err.message, + code: err.errorCode ?? `${err.constructor.name + .replace(/([A-Z])/g, "_$1") + .toUpperCase() + .replace(/^_/, "")}`, + category: err.errorCategory, + statusCode: err.statusCode, + }; +} + export class NotFoundError extends ApplicationError { constructor(message: string, statusCode = 404) { super(message, statusCode);