From b00eaa1912c391f7f11932113c422fa4d9200c72 Mon Sep 17 00:00:00 2001 From: Shodipo Micheal Date: Fri, 28 Aug 2026 13:05:58 +0100 Subject: [PATCH 01/14] Fix issue #7: update packages/client/src/errors.ts --- packages/client/src/errors.ts | 160 ++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 packages/client/src/errors.ts diff --git a/packages/client/src/errors.ts b/packages/client/src/errors.ts new file mode 100644 index 0000000..2cce2fc --- /dev/null +++ b/packages/client/src/errors.ts @@ -0,0 +1,160 @@ +/** + * Robust error mapping and error parser utility for `@astroid/client`. + */ + +import { + AstroidError, + ValidationError, + PolicyViolationError, + fromApiError, + fromStatus, + type AstroidErrorOptions, +} from '@astroid/errors'; + +/** + * Base SDK Error for `@astroid/client`, preserving status codes, tracing headers, + * and underlying error callstacks via the standard `cause` property. + */ +export class AstroidSDKError extends AstroidError { + readonly headers: Headers; + + constructor(message: string, options: AstroidErrorOptions & { headers?: Headers; cause?: unknown }) { + super(message, options); + this.headers = options.headers ?? new Headers(); + } +} + +/** + * Validation error mapping specific properties directly to input keys. + */ +export class AstroidValidationError extends AstroidSDKError { + get fieldErrors(): Record | undefined { + return (this.details?.fields ?? this.details) as Record | undefined; + } +} + +/** + * Policy violation error containing policy block codes or details. + */ +export class AstroidPolicyViolationError extends AstroidSDKError { + get policyCodes(): string[] | undefined { + const codes = this.details?.policyCodes ?? this.details?.codes; + return Array.isArray(codes) ? (codes as string[]) : undefined; + } +} + +/** + * Error wrapping Stellar Horizon specific codes (e.g. op_underfunded, tx_bad_seq). + */ +export class AstroidHorizonError extends AstroidSDKError { + get horizonCode(): string | undefined { + return (this.details?.horizonCode ?? this.details?.code) as string | undefined; + } + + get stellarResultCodes(): Record | undefined { + return this.details?.resultCodes as Record | undefined; + } +} + +/** + * Securely inspects response content-type and parses raw response bodies into + * structured, typed, and context-rich hierarchy of custom classes. + */ +export async function parseErrorResponse(response: Response): Promise { + const status = response.status; + const requestId = response.headers.get('x-request-id') ?? undefined; + const contentType = response.headers.get('content-type') ?? ''; + + let rawBody: unknown = undefined; + let text = ''; + + if (contentType.includes('application/json')) { + try { + text = await response.text(); + if (text) { + rawBody = JSON.parse(text); + } + } catch (parseErr) { + rawBody = { raw: text }; + } + } else { + try { + text = await response.text(); + rawBody = text ? { raw: text } : undefined; + } catch { + rawBody = undefined; + } + } + + const errorObj = rawBody && typeof rawBody === 'object' && 'error' in rawBody + ? (rawBody as { error: any }).error + : rawBody; + + let code = 'INTERNAL_ERROR'; + let message = response.statusText || 'Unknown error'; + let details: Record | undefined = undefined; + + if (errorObj && typeof errorObj === 'object') { + if (typeof (errorObj as any).code === 'string') { + code = (errorObj as any).code; + } + if (typeof (errorObj as any).message === 'string') { + message = (errorObj as any).message; + } + if ((errorObj as any).details && typeof (errorObj as any).details === 'object') { + details = (errorObj as any).details; + } else { + const rest = { ...(errorObj as Record) }; + delete rest.code; + delete rest.message; + if (Object.keys(rest).length > 0) { + details = rest; + } + } + } + + // Check for Stellar Horizon specific codes in details or top-level + const horizonCandidate = details?.horizonCode ?? details?.stellarCode ?? (rawBody as any)?.horizonCode; + if (horizonCandidate || code === 'HORIZON_ERROR' || code.startsWith('op_') || code.startsWith('tx_')) { + return new AstroidHorizonError(message, { + code, + status, + requestId, + details: { ...details, horizonCode: horizonCandidate ?? code }, + headers: response.headers, + }); + } + + if (status === 422 || code === 'POLICY_VIOLATION') { + return new AstroidPolicyViolationError(message, { + code, + status, + requestId, + details, + headers: response.headers, + }); + } + + if (status === 400 || status === 422 || code === 'VALIDATION_ERROR') { + return new AstroidValidationError(message, { + code, + status, + requestId, + details, + headers: response.headers, + }); + } + + const baseMapped = fromApiError( + { code, message, details }, + { status, requestId, details } + ); + + return new AstroidSDKError(baseMapped.message, { + code: baseMapped.code, + status: baseMapped.status, + requestId: baseMapped.requestId, + details: baseMapped.details, + headers: response.headers, + }); +} From 8d9e79f4520365d98537413bcf2097c9853875e8 Mon Sep 17 00:00:00 2001 From: Shodipo Micheal Date: Fri, 28 Aug 2026 13:05:59 +0100 Subject: [PATCH 02/14] Fix issue #7: update packages/client/src/errors.test.ts --- packages/client/src/errors.test.ts | 118 +++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 packages/client/src/errors.test.ts diff --git a/packages/client/src/errors.test.ts b/packages/client/src/errors.test.ts new file mode 100644 index 0000000..113e77b --- /dev/null +++ b/packages/client/src/errors.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from 'vitest'; +import { + parseErrorResponse, + AstroidSDKError, + AstroidValidationError, + AstroidPolicyViolationError, + AstroidHorizonError, +} from './errors.js'; + +describe('@astroid/client error mapping & parsing', () => { + it('parses HTTP 400 validation array into AstroidValidationError with field mappings', async () => { + const response = new Response( + JSON.stringify({ + error: { + code: 'VALIDATION_ERROR', + message: 'Invalid input parameters', + details: { + fields: { + email: ['must be a valid email'], + amount: ['must be greater than 0'], + }, + }, + }, + }), + { + status: 400, + headers: { + 'content-type': 'application/json', + 'x-request-id': 'req_val_123', + }, + } + ); + + const err = await parseErrorResponse(response); + expect(err).toBeInstanceOf(AstroidValidationError); + expect(err).toBeInstanceOf(AstroidSDKError); + expect(err.code).toBe('VALIDATION_ERROR'); + expect(err.status).toBe(400); + expect(err.requestId).toBe('req_val_123'); + expect((err as AstroidValidationError).fieldErrors).toEqual({ + email: ['must be a valid email'], + amount: ['must be greater than 0'], + }); + }); + + it('parses HTTP 422 policy blocking error into AstroidPolicyViolationError', async () => { + const response = new Response( + JSON.stringify({ + error: { + code: 'POLICY_VIOLATION', + message: 'Transaction blocked by policy rules', + details: { + policyCodes: ['MAX_AMOUNT_EXCEEDED'], + }, + }, + }), + { + status: 422, + headers: { + 'content-type': 'application/json', + 'x-request-id': 'req_pol_456', + }, + } + ); + + const err = await parseErrorResponse(response); + expect(err).toBeInstanceOf(AstroidPolicyViolationError); + expect(err.code).toBe('POLICY_VIOLATION'); + expect(err.status).toBe(422); + expect((err as AstroidPolicyViolationError).policyCodes).toEqual(['MAX_AMOUNT_EXCEEDED']); + }); + + it('parses Stellar Horizon specific error codes into AstroidHorizonError', async () => { + const response = new Response( + JSON.stringify({ + error: { + code: 'op_underfunded', + message: 'Operation failed due to lack of funds', + details: { + horizonCode: 'op_underfunded', + resultCodes: { transaction: 'tx_failed', operation: 'op_underfunded' }, + }, + }, + }), + { + status: 400, + headers: { + 'content-type': 'application/json', + 'x-request-id': 'req_hor_789', + }, + } + ); + + const err = await parseErrorResponse(response); + expect(err).toBeInstanceOf(AstroidHorizonError); + expect((err as AstroidHorizonError).horizonCode).toBe('op_underfunded'); + expect((err as AstroidHorizonError).stellarResultCodes).toEqual({ + transaction: 'tx_failed', + operation: 'op_underfunded', + }); + }); + + it('parses HTTP 500 internal crash with non-json or plain text safely', async () => { + const response = new Response('Internal Server Error Crash', { + status: 500, + headers: { + 'content-type': 'text/plain', + 'x-request-id': 'req_500_abc', + }, + }); + + const err = await parseErrorResponse(response); + expect(err).toBeInstanceOf(AstroidSDKError); + expect(err.status).toBe(500); + expect(err.requestId).toBe('req_500_abc'); + expect(err.details).toEqual({ raw: 'Internal Server Error Crash' }); + }); +}); From c6b898ccca47e57bd49bffa79bded135cddcaccb Mon Sep 17 00:00:00 2001 From: Shodipo Micheal Date: Fri, 28 Aug 2026 13:14:48 +0100 Subject: [PATCH 03/14] Fix issue #7: update packages/client/src/errors.ts --- packages/client/src/errors.ts | 218 ++++++++++++++++------------------ 1 file changed, 100 insertions(+), 118 deletions(-) diff --git a/packages/client/src/errors.ts b/packages/client/src/errors.ts index 2cce2fc..d70a73c 100644 --- a/packages/client/src/errors.ts +++ b/packages/client/src/errors.ts @@ -1,160 +1,142 @@ /** - * Robust error mapping and error parser utility for `@astroid/client`. + * `@astroid/client` error mapping and parsing utilities. */ import { AstroidError, - ValidationError, - PolicyViolationError, + AuthenticationError, + AuthorizationError, + ConflictError, + NotFoundError, + RateLimitError, + ServerError, fromApiError, - fromStatus, - type AstroidErrorOptions, + codeForStatus, } from '@astroid/errors'; -/** - * Base SDK Error for `@astroid/client`, preserving status codes, tracing headers, - * and underlying error callstacks via the standard `cause` property. - */ -export class AstroidSDKError extends AstroidError { - readonly headers: Headers; +/** Base SDK error class alias or re-export */ +export class AstroidSDKError extends AstroidError {} - constructor(message: string, options: AstroidErrorOptions & { headers?: Headers; cause?: unknown }) { - super(message, options); - this.headers = options.headers ?? new Headers(); - } +/** Stellar Horizon ledger / transaction error details */ +export interface HorizonErrorDetails { + stellarErrorCode?: string; + horizonCode?: string; + extras?: Record; + [key: string]: unknown; } -/** - * Validation error mapping specific properties directly to input keys. - */ -export class AstroidValidationError extends AstroidSDKError { - get fieldErrors(): Record | undefined { - return (this.details?.fields ?? this.details) as Record | undefined; - } -} +/** Error when Stellar Horizon encounters transaction/operation rejections */ +export class AstroidHorizonError extends AstroidError { + readonly stellarErrorCode: string | undefined; + readonly horizonCode: string | undefined; -/** - * Policy violation error containing policy block codes or details. - */ -export class AstroidPolicyViolationError extends AstroidSDKError { - get policyCodes(): string[] | undefined { - const codes = this.details?.policyCodes ?? this.details?.codes; - return Array.isArray(codes) ? (codes as string[]) : undefined; + constructor(message: string, options: { code: string; status?: number; requestId?: string; details?: Record; cause?: unknown }) { + super(message, options); + const details = options.details as HorizonErrorDetails | undefined; + this.stellarErrorCode = details?.stellarErrorCode ?? (details?.extras as Record)?.result_codes as string | undefined; + this.horizonCode = details?.horizonCode; } } -/** - * Error wrapping Stellar Horizon specific codes (e.g. op_underfunded, tx_bad_seq). - */ -export class AstroidHorizonError extends AstroidSDKError { - get horizonCode(): string | undefined { - return (this.details?.horizonCode ?? this.details?.code) as string | undefined; - } +/** Error when an operation violates spending or operational policies */ +export class AstroidPolicyViolationError extends AstroidError {} - get stellarResultCodes(): Record | undefined { - return this.details?.resultCodes as Record | undefined; +/** Error when field validation fails */ +export class AstroidValidationError extends AstroidError { + get fieldErrors(): Record | undefined { + return this.details?.fields as Record | undefined; } } /** - * Securely inspects response content-type and parses raw response bodies into - * structured, typed, and context-rich hierarchy of custom classes. + * Parse a raw Response or response body/headers into a structured Astroid SDK error. */ -export async function parseErrorResponse(response: Response): Promise { - const status = response.status; - const requestId = response.headers.get('x-request-id') ?? undefined; - const contentType = response.headers.get('content-type') ?? ''; - - let rawBody: unknown = undefined; - let text = ''; +export async function parseAstroidError(response: Response): Promise { + let status = response.status; + let requestId = response.headers.get('x-request-id') ?? undefined; + let body: unknown; + const contentType = response.headers.get('content-type') || ''; if (contentType.includes('application/json')) { try { - text = await response.text(); - if (text) { - rawBody = JSON.parse(text); - } - } catch (parseErr) { - rawBody = { raw: text }; + body = await response.json(); + } catch { + body = undefined; } } else { try { - text = await response.text(); - rawBody = text ? { raw: text } : undefined; + const text = await response.text(); + if (text) { + try { + body = JSON.parse(text); + } catch { + body = { message: text }; + } + } } catch { - rawBody = undefined; + body = undefined; } } - const errorObj = rawBody && typeof rawBody === 'object' && 'error' in rawBody - ? (rawBody as { error: any }).error - : rawBody; + let apiError: { code?: string; message?: string; details?: Record } | undefined; - let code = 'INTERNAL_ERROR'; - let message = response.statusText || 'Unknown error'; - let details: Record | undefined = undefined; + if (body && typeof body === 'object') { + const b = body as Record; + if (b.error && typeof b.error === 'object') { + apiError = b.error as Record; + } else if (b.code && typeof b.code === 'string') { + apiError = b as Record; + } + } + + const code = apiError?.code ?? codeForStatus(status); + const message = apiError?.message ?? (body && typeof body === 'object' && typeof (body as Record).message === 'string' ? ((body as Record).message as string) : response.statusText || 'Unknown error'); + const details = (apiError?.details ?? (body && typeof body === 'object' ? (body as Record).details : undefined)) as Record | undefined; - if (errorObj && typeof errorObj === 'object') { - if (typeof (errorObj as any).code === 'string') { - code = (errorObj as any).code; + const errPayload = { + code, + status, + requestId, + details, + }; + + if (code === 'POLICY_VIOLATION' || status === 422) { + if (details?.stellarErrorCode || details?.horizonCode || details?.result_codes || code?.startsWith('op_') || code?.startsWith('tx_')) { + return new AstroidHorizonError(message, errPayload); } - if (typeof (errorObj as any).message === 'string') { - message = (errorObj as any).message; + if (code === 'POLICY_VIOLATION') { + return new AstroidPolicyViolationError(message, errPayload); } - if ((errorObj as any).details && typeof (errorObj as any).details === 'object') { - details = (errorObj as any).details; - } else { - const rest = { ...(errorObj as Record) }; - delete rest.code; - delete rest.message; - if (Object.keys(rest).length > 0) { - details = rest; - } + if (code === 'VALIDATION_ERROR' || status === 400 || status === 422) { + return new AstroidValidationError(message, errPayload); } } - // Check for Stellar Horizon specific codes in details or top-level - const horizonCandidate = details?.horizonCode ?? details?.stellarCode ?? (rawBody as any)?.horizonCode; - if (horizonCandidate || code === 'HORIZON_ERROR' || code.startsWith('op_') || code.startsWith('tx_')) { - return new AstroidHorizonError(message, { - code, - status, - requestId, - details: { ...details, horizonCode: horizonCandidate ?? code }, - headers: response.headers, - }); + if (details?.stellarErrorCode || details?.horizonCode || code?.startsWith('op_') || code?.startsWith('tx_')) { + return new AstroidHorizonError(message, errPayload); } - if (status === 422 || code === 'POLICY_VIOLATION') { - return new AstroidPolicyViolationError(message, { - code, - status, - requestId, - details, - headers: response.headers, - }); + switch (code) { + case 'AUTHENTICATION_ERROR': + case 'UNAUTHORIZED': + case 'INVALID_API_KEY': + case 'TOKEN_EXPIRED': + return new AuthenticationError(message, errPayload); + case 'FORBIDDEN': + return new AuthorizationError(message, errPayload); + case 'VALIDATION_ERROR': + case 'BAD_REQUEST': + return new AstroidValidationError(message, errPayload); + case 'NOT_FOUND': + return new NotFoundError(message, errPayload); + case 'CONFLICT': + return new ConflictError(message, errPayload); + case 'RATE_LIMITED': + return new RateLimitError(message, errPayload); + case 'INTERNAL_ERROR': + case 'SERVICE_UNAVAILABLE': + return new ServerError(message, errPayload); + default: + return fromApiError({ code, message, details }, { status, requestId }); } - - if (status === 400 || status === 422 || code === 'VALIDATION_ERROR') { - return new AstroidValidationError(message, { - code, - status, - requestId, - details, - headers: response.headers, - }); - } - - const baseMapped = fromApiError( - { code, message, details }, - { status, requestId, details } - ); - - return new AstroidSDKError(baseMapped.message, { - code: baseMapped.code, - status: baseMapped.status, - requestId: baseMapped.requestId, - details: baseMapped.details, - headers: response.headers, - }); } From 24939f06e43448e2734190e38a952291198bd921 Mon Sep 17 00:00:00 2001 From: Shodipo Micheal Date: Fri, 28 Aug 2026 13:14:49 +0100 Subject: [PATCH 04/14] Fix issue #7: update packages/client/src/errors.test.ts --- packages/client/src/errors.test.ts | 151 ++++++++++++++++------------- 1 file changed, 85 insertions(+), 66 deletions(-) diff --git a/packages/client/src/errors.test.ts b/packages/client/src/errors.test.ts index 113e77b..d6bd338 100644 --- a/packages/client/src/errors.test.ts +++ b/packages/client/src/errors.test.ts @@ -1,118 +1,137 @@ import { describe, it, expect } from 'vitest'; import { - parseErrorResponse, AstroidSDKError, AstroidValidationError, - AstroidPolicyViolationError, AstroidHorizonError, + AstroidPolicyViolationError, + parseAstroidError, } from './errors.js'; +import { AuthenticationError, ServerError } from '@astroid/errors'; + +describe('@astroid/client errors and parser', () => { + it('defines the custom error hierarchy', () => { + const base = new AstroidSDKError('test', { code: 'TEST_ERROR', status: 400 }); + expect(base).toBeInstanceOf(AstroidSDKError); + + const valErr = new AstroidValidationError('invalid', { + code: 'VALIDATION_ERROR', + status: 400, + details: { fields: { email: ['required'] } }, + }); + expect(valErr).toBeInstanceOf(AstroidSDKError); + expect(valErr.fieldErrors).toEqual({ email: ['required'] }); + + const horizonErr = new AstroidHorizonError('stellar failed', { + code: 'op_underfunded', + status: 400, + details: { stellarErrorCode: 'op_underfunded', horizonCode: 'bad_req' }, + }); + expect(horizonErr).toBeInstanceOf(AstroidSDKError); + expect(horizonErr.stellarErrorCode).toBe('op_underfunded'); + expect(horizonErr.horizonCode).toBe('bad_req'); -describe('@astroid/client error mapping & parsing', () => { - it('parses HTTP 400 validation array into AstroidValidationError with field mappings', async () => { - const response = new Response( + const policyErr = new AstroidPolicyViolationError('blocked', { + code: 'POLICY_VIOLATION', + status: 422, + }); + expect(policyErr).toBeInstanceOf(AstroidSDKError); + }); + + it('parses HTTP 400 validation array payload correctly', async () => { + const res = new Response( JSON.stringify({ error: { code: 'VALIDATION_ERROR', - message: 'Invalid input parameters', - details: { - fields: { - email: ['must be a valid email'], - amount: ['must be greater than 0'], - }, - }, + message: 'Validation failed', + details: { fields: { amount: ['must be positive'] } }, }, }), { status: 400, - headers: { - 'content-type': 'application/json', - 'x-request-id': 'req_val_123', - }, - } + headers: { 'content-type': 'application/json', 'x-request-id': 'req_val_1' }, + }, ); - const err = await parseErrorResponse(response); + const err = await parseAstroidError(res); expect(err).toBeInstanceOf(AstroidValidationError); - expect(err).toBeInstanceOf(AstroidSDKError); expect(err.code).toBe('VALIDATION_ERROR'); expect(err.status).toBe(400); - expect(err.requestId).toBe('req_val_123'); - expect((err as AstroidValidationError).fieldErrors).toEqual({ - email: ['must be a valid email'], - amount: ['must be greater than 0'], - }); + expect(err.requestId).toBe('req_val_1'); + expect((err as AstroidValidationError).fieldErrors).toEqual({ amount: ['must be positive'] }); }); - it('parses HTTP 422 policy blocking error into AstroidPolicyViolationError', async () => { - const response = new Response( + it('parses HTTP 422 policy blocking payload correctly', async () => { + const res = new Response( JSON.stringify({ error: { code: 'POLICY_VIOLATION', - message: 'Transaction blocked by policy rules', - details: { - policyCodes: ['MAX_AMOUNT_EXCEEDED'], - }, + message: 'Policy check failed', + details: { policyId: 'pol_99' }, }, }), { status: 422, - headers: { - 'content-type': 'application/json', - 'x-request-id': 'req_pol_456', - }, - } + headers: { 'content-type': 'application/json', 'x-request-id': 'req_policy_1' }, + }, ); - const err = await parseErrorResponse(response); + const err = await parseAstroidError(res); expect(err).toBeInstanceOf(AstroidPolicyViolationError); expect(err.code).toBe('POLICY_VIOLATION'); expect(err.status).toBe(422); - expect((err as AstroidPolicyViolationError).policyCodes).toEqual(['MAX_AMOUNT_EXCEEDED']); + expect(err.requestId).toBe('req_policy_1'); + expect(err.details).toEqual({ policyId: 'pol_99' }); }); - it('parses Stellar Horizon specific error codes into AstroidHorizonError', async () => { - const response = new Response( + it('parses Stellar Horizon specific error codes correctly', async () => { + const res = new Response( JSON.stringify({ error: { code: 'op_underfunded', - message: 'Operation failed due to lack of funds', - details: { - horizonCode: 'op_underfunded', - resultCodes: { transaction: 'tx_failed', operation: 'op_underfunded' }, - }, + message: 'Transaction failed on Stellar ledger', + details: { stellarErrorCode: 'op_underfunded', horizonCode: 'tx_failed' }, }, }), { status: 400, - headers: { - 'content-type': 'application/json', - 'x-request-id': 'req_hor_789', - }, - } + headers: { 'content-type': 'application/json', 'x-request-id': 'req_hz_1' }, + }, ); - const err = await parseErrorResponse(response); + const err = await parseAstroidError(res); expect(err).toBeInstanceOf(AstroidHorizonError); - expect((err as AstroidHorizonError).horizonCode).toBe('op_underfunded'); - expect((err as AstroidHorizonError).stellarResultCodes).toEqual({ - transaction: 'tx_failed', - operation: 'op_underfunded', - }); + expect((err as AstroidHorizonError).stellarErrorCode).toBe('op_underfunded'); + expect((err as AstroidHorizonError).horizonCode).toBe('tx_failed'); }); - it('parses HTTP 500 internal crash with non-json or plain text safely', async () => { - const response = new Response('Internal Server Error Crash', { - status: 500, - headers: { - 'content-type': 'text/plain', - 'x-request-id': 'req_500_abc', + it('parses HTTP 500 internal crash payload correctly', async () => { + const res = new Response( + JSON.stringify({ + error: { + code: 'INTERNAL_ERROR', + message: 'Internal server error occurred', + }, + }), + { + status: 500, + headers: { 'content-type': 'application/json', 'x-request-id': 'req_500_1' }, }, - }); + ); - const err = await parseErrorResponse(response); - expect(err).toBeInstanceOf(AstroidSDKError); + const err = await parseAstroidError(res); + expect(err).toBeInstanceOf(ServerError); + expect(err.code).toBe('INTERNAL_ERROR'); expect(err.status).toBe(500); - expect(err.requestId).toBe('req_500_abc'); - expect(err.details).toEqual({ raw: 'Internal Server Error Crash' }); + }); + + it('handles non-JSON error responses gracefully', async () => { + const res = new Response('Gateway Timeout', { + status: 504, + headers: { 'content-type': 'text/plain' }, + }); + + const err = await parseAstroidError(res); + expect(err.status).toBe(504); + expect(err.message).toContain('Gateway Timeout'); }); }); From 876fc80159ffff43d650c640338f3fb88fe72726 Mon Sep 17 00:00:00 2001 From: Shodipo Micheal Date: Fri, 28 Aug 2026 13:19:59 +0100 Subject: [PATCH 05/14] Fix issue #7: update packages/client/src/errors.ts --- packages/client/src/errors.ts | 256 +++++++++++++++++++--------------- 1 file changed, 144 insertions(+), 112 deletions(-) diff --git a/packages/client/src/errors.ts b/packages/client/src/errors.ts index d70a73c..647e28b 100644 --- a/packages/client/src/errors.ts +++ b/packages/client/src/errors.ts @@ -1,142 +1,174 @@ /** - * `@astroid/client` error mapping and parsing utilities. + * `@astroid/client/errors` — robust error hierarchy, parser, and mapping layer. */ -import { - AstroidError, - AuthenticationError, - AuthorizationError, - ConflictError, - NotFoundError, - RateLimitError, - ServerError, - fromApiError, - codeForStatus, -} from '@astroid/errors'; - -/** Base SDK error class alias or re-export */ -export class AstroidSDKError extends AstroidError {} - -/** Stellar Horizon ledger / transaction error details */ -export interface HorizonErrorDetails { - stellarErrorCode?: string; +import type { ApiError } from '@astroid/types'; + +export interface AstroidSDKErrorOptions { + code: string; + status?: number; + requestId?: string; + details?: Record; + cause?: unknown; horizonCode?: string; - extras?: Record; - [key: string]: unknown; + validationErrors?: Record; } -/** Error when Stellar Horizon encounters transaction/operation rejections */ -export class AstroidHorizonError extends AstroidError { - readonly stellarErrorCode: string | undefined; +export class AstroidSDKError extends Error { + readonly code: string; + readonly status: number | undefined; + readonly requestId: string | undefined; + readonly details: Record | undefined; readonly horizonCode: string | undefined; + readonly validationErrors: Record | undefined; + + constructor(message: string, options: AstroidSDKErrorOptions) { + super(message, options.cause !== undefined ? { cause: options.cause } : undefined); + this.name = new.target.name; + this.code = options.code; + this.status = options.status; + this.requestId = options.requestId; + this.details = options.details; + this.horizonCode = options.horizonCode; + this.validationErrors = options.validationErrors; + Object.setPrototypeOf(this, new.target.prototype); + } - constructor(message: string, options: { code: string; status?: number; requestId?: string; details?: Record; cause?: unknown }) { - super(message, options); - const details = options.details as HorizonErrorDetails | undefined; - this.stellarErrorCode = details?.stellarErrorCode ?? (details?.extras as Record)?.result_codes as string | undefined; - this.horizonCode = details?.horizonCode; + toJSON(): Record { + return { + name: this.name, + message: this.message, + code: this.code, + status: this.status, + requestId: this.requestId, + details: this.details, + horizonCode: this.horizonCode, + validationErrors: this.validationErrors, + }; } } -/** Error when an operation violates spending or operational policies */ -export class AstroidPolicyViolationError extends AstroidError {} +export class AstroidValidationError extends AstroidSDKError { + constructor(message: string, options: AstroidSDKErrorOptions) { + super(message, { code: 'VALIDATION_ERROR', ...options }); + } +} -/** Error when field validation fails */ -export class AstroidValidationError extends AstroidError { - get fieldErrors(): Record | undefined { - return this.details?.fields as Record | undefined; +export class AstroidHorizonError extends AstroidSDKError { + constructor(message: string, options: AstroidSDKErrorOptions) { + super(message, { code: 'HORIZON_ERROR', ...options }); } } -/** - * Parse a raw Response or response body/headers into a structured Astroid SDK error. - */ -export async function parseAstroidError(response: Response): Promise { - let status = response.status; - let requestId = response.headers.get('x-request-id') ?? undefined; - let body: unknown; - - const contentType = response.headers.get('content-type') || ''; - if (contentType.includes('application/json')) { - try { - body = await response.json(); - } catch { - body = undefined; - } - } else { - try { - const text = await response.text(); - if (text) { - try { - body = JSON.parse(text); - } catch { - body = { message: text }; - } - } - } catch { - body = undefined; - } +export class AstroidPolicyViolationError extends AstroidSDKError { + constructor(message: string, options: AstroidSDKErrorOptions) { + super(message, { code: 'POLICY_VIOLATION', ...options }); + } +} + +export class AstroidServerInternalError extends AstroidSDKError { + constructor(message: string, options: AstroidSDKErrorOptions) { + super(message, { code: 'INTERNAL_ERROR', ...options }); } +} - let apiError: { code?: string; message?: string; details?: Record } | undefined; +export function parseAstroidError( + status: number, + body: unknown, + requestId?: string, + cause?: unknown, +): AstroidSDKError { + let code = 'UNKNOWN_ERROR'; + let message = `Request failed with status ${status}`; + let details: Record | undefined; + let horizonCode: string | undefined; + let validationErrors: Record | undefined; if (body && typeof body === 'object') { - const b = body as Record; - if (b.error && typeof b.error === 'object') { - apiError = b.error as Record; - } else if (b.code && typeof b.code === 'string') { - apiError = b as Record; + const obj = body as Record; + if (typeof obj['code'] === 'string') { + code = obj['code']; + } + if (typeof obj['message'] === 'string') { + message = obj['message']; + } else if (obj['error'] && typeof obj['error'] === 'object') { + const innerErr = obj['error'] as Record; + if (typeof innerErr['code'] === 'string') code = innerErr['code']; + if (typeof innerErr['message'] === 'string') message = innerErr['message']; + if (innerErr['details'] && typeof innerErr['details'] === 'object') { + details = innerErr['details'] as Record; + } + } + if (obj['details'] && typeof obj['details'] === 'object') { + details = { ...(details ?? {}), ...(obj['details'] as Record) }; + } + if (typeof obj['horizonCode'] === 'string') { + horizonCode = obj['horizonCode']; + } else if (details && typeof details['horizonCode'] === 'string') { + horizonCode = details['horizonCode'] as string; + } + if (obj['validationErrors'] && typeof obj['validationErrors'] === 'object') { + validationErrors = obj['validationErrors'] as Record; + } else if (details && details['fields'] && typeof details['fields'] === 'object') { + validationErrors = details['fields'] as Record; } } - const code = apiError?.code ?? codeForStatus(status); - const message = apiError?.message ?? (body && typeof body === 'object' && typeof (body as Record).message === 'string' ? ((body as Record).message as string) : response.statusText || 'Unknown error'); - const details = (apiError?.details ?? (body && typeof body === 'object' ? (body as Record).details : undefined)) as Record | undefined; - - const errPayload = { - code, - status, - requestId, - details, - }; + if (horizonCode || code.includes('HORIZON') || code === 'op_underfunded' || code === 'tx_bad_seq') { + return new AstroidHorizonError(message, { + code, + status, + requestId, + details, + horizonCode: horizonCode ?? code, + validationErrors, + cause, + }); + } if (code === 'POLICY_VIOLATION' || status === 422) { - if (details?.stellarErrorCode || details?.horizonCode || details?.result_codes || code?.startsWith('op_') || code?.startsWith('tx_')) { - return new AstroidHorizonError(message, errPayload); - } - if (code === 'POLICY_VIOLATION') { - return new AstroidPolicyViolationError(message, errPayload); - } - if (code === 'VALIDATION_ERROR' || status === 400 || status === 422) { - return new AstroidValidationError(message, errPayload); - } + return new AstroidPolicyViolationError(message, { + code, + status, + requestId, + details, + horizonCode, + validationErrors, + cause, + }); } - if (details?.stellarErrorCode || details?.horizonCode || code?.startsWith('op_') || code?.startsWith('tx_')) { - return new AstroidHorizonError(message, errPayload); + if (code === 'VALIDATION_ERROR' || status === 400 || validationErrors) { + return new AstroidValidationError(message, { + code, + status, + requestId, + details, + horizonCode, + validationErrors, + cause, + }); } - switch (code) { - case 'AUTHENTICATION_ERROR': - case 'UNAUTHORIZED': - case 'INVALID_API_KEY': - case 'TOKEN_EXPIRED': - return new AuthenticationError(message, errPayload); - case 'FORBIDDEN': - return new AuthorizationError(message, errPayload); - case 'VALIDATION_ERROR': - case 'BAD_REQUEST': - return new AstroidValidationError(message, errPayload); - case 'NOT_FOUND': - return new NotFoundError(message, errPayload); - case 'CONFLICT': - return new ConflictError(message, errPayload); - case 'RATE_LIMITED': - return new RateLimitError(message, errPayload); - case 'INTERNAL_ERROR': - case 'SERVICE_UNAVAILABLE': - return new ServerError(message, errPayload); - default: - return fromApiError({ code, message, details }, { status, requestId }); + if (status >= 500) { + return new AstroidServerInternalError(message, { + code, + status, + requestId, + details, + horizonCode, + validationErrors, + cause, + }); } + + return new AstroidSDKError(message, { + code, + status, + requestId, + details, + horizonCode, + validationErrors, + cause, + }); } From dbaa143078ec6e9fad6e2e7017aa32332486519f Mon Sep 17 00:00:00 2001 From: Shodipo Micheal Date: Fri, 28 Aug 2026 13:20:00 +0100 Subject: [PATCH 06/14] Fix issue #7: update packages/client/src/errors.test.ts --- packages/client/src/errors.test.ts | 143 ++++++++--------------------- 1 file changed, 36 insertions(+), 107 deletions(-) diff --git a/packages/client/src/errors.test.ts b/packages/client/src/errors.test.ts index d6bd338..8be2419 100644 --- a/packages/client/src/errors.test.ts +++ b/packages/client/src/errors.test.ts @@ -4,134 +4,63 @@ import { AstroidValidationError, AstroidHorizonError, AstroidPolicyViolationError, + AstroidServerInternalError, parseAstroidError, } from './errors.js'; -import { AuthenticationError, ServerError } from '@astroid/errors'; describe('@astroid/client errors and parser', () => { - it('defines the custom error hierarchy', () => { - const base = new AstroidSDKError('test', { code: 'TEST_ERROR', status: 400 }); - expect(base).toBeInstanceOf(AstroidSDKError); - - const valErr = new AstroidValidationError('invalid', { + it('parses HTTP 400 validation error correctly with field errors', () => { + const payload = { code: 'VALIDATION_ERROR', - status: 400, - details: { fields: { email: ['required'] } }, - }); - expect(valErr).toBeInstanceOf(AstroidSDKError); - expect(valErr.fieldErrors).toEqual({ email: ['required'] }); - - const horizonErr = new AstroidHorizonError('stellar failed', { - code: 'op_underfunded', - status: 400, - details: { stellarErrorCode: 'op_underfunded', horizonCode: 'bad_req' }, - }); - expect(horizonErr).toBeInstanceOf(AstroidSDKError); - expect(horizonErr.stellarErrorCode).toBe('op_underfunded'); - expect(horizonErr.horizonCode).toBe('bad_req'); - - const policyErr = new AstroidPolicyViolationError('blocked', { - code: 'POLICY_VIOLATION', - status: 422, - }); - expect(policyErr).toBeInstanceOf(AstroidSDKError); - }); - - it('parses HTTP 400 validation array payload correctly', async () => { - const res = new Response( - JSON.stringify({ - error: { - code: 'VALIDATION_ERROR', - message: 'Validation failed', - details: { fields: { amount: ['must be positive'] } }, - }, - }), - { - status: 400, - headers: { 'content-type': 'application/json', 'x-request-id': 'req_val_1' }, - }, - ); - - const err = await parseAstroidError(res); + message: 'Validation failed', + validationErrors: { email: ['invalid format'] }, + }; + const err = parseAstroidError(400, payload, 'req_123'); expect(err).toBeInstanceOf(AstroidValidationError); expect(err.code).toBe('VALIDATION_ERROR'); expect(err.status).toBe(400); - expect(err.requestId).toBe('req_val_1'); - expect((err as AstroidValidationError).fieldErrors).toEqual({ amount: ['must be positive'] }); + expect(err.requestId).toBe('req_123'); + expect(err.validationErrors).toEqual({ email: ['invalid format'] }); }); - it('parses HTTP 422 policy blocking payload correctly', async () => { - const res = new Response( - JSON.stringify({ - error: { - code: 'POLICY_VIOLATION', - message: 'Policy check failed', - details: { policyId: 'pol_99' }, - }, - }), - { - status: 422, - headers: { 'content-type': 'application/json', 'x-request-id': 'req_policy_1' }, - }, - ); - - const err = await parseAstroidError(res); + it('parses HTTP 422 policy blocking error correctly', () => { + const payload = { + code: 'POLICY_VIOLATION', + message: 'Transaction exceeds max allowed limit', + details: { policyId: 'pol_99' }, + }; + const err = parseAstroidError(422, payload, 'req_456'); expect(err).toBeInstanceOf(AstroidPolicyViolationError); expect(err.code).toBe('POLICY_VIOLATION'); expect(err.status).toBe(422); - expect(err.requestId).toBe('req_policy_1'); expect(err.details).toEqual({ policyId: 'pol_99' }); }); - it('parses Stellar Horizon specific error codes correctly', async () => { - const res = new Response( - JSON.stringify({ - error: { - code: 'op_underfunded', - message: 'Transaction failed on Stellar ledger', - details: { stellarErrorCode: 'op_underfunded', horizonCode: 'tx_failed' }, - }, - }), - { - status: 400, - headers: { 'content-type': 'application/json', 'x-request-id': 'req_hz_1' }, - }, - ); - - const err = await parseAstroidError(res); + it('parses Stellar Horizon specific error codes and exposes horizonCode', () => { + const payload = { + code: 'op_underfunded', + message: 'Stellar operation underfunded', + horizonCode: 'op_underfunded', + }; + const err = parseAstroidError(400, payload, 'req_789'); expect(err).toBeInstanceOf(AstroidHorizonError); - expect((err as AstroidHorizonError).stellarErrorCode).toBe('op_underfunded'); - expect((err as AstroidHorizonError).horizonCode).toBe('tx_failed'); + expect((err as AstroidHorizonError).horizonCode).toBe('op_underfunded'); }); - it('parses HTTP 500 internal crash payload correctly', async () => { - const res = new Response( - JSON.stringify({ - error: { - code: 'INTERNAL_ERROR', - message: 'Internal server error occurred', - }, - }), - { - status: 500, - headers: { 'content-type': 'application/json', 'x-request-id': 'req_500_1' }, - }, - ); - - const err = await parseAstroidError(res); - expect(err).toBeInstanceOf(ServerError); - expect(err.code).toBe('INTERNAL_ERROR'); + it('parses HTTP 500 internal crash correctly', () => { + const payload = { + code: 'INTERNAL_ERROR', + message: 'Database connection failed', + }; + const err = parseAstroidError(500, payload, 'req_500'); + expect(err).toBeInstanceOf(AstroidServerInternalError); expect(err.status).toBe(500); + expect(err.code).toBe('INTERNAL_ERROR'); }); - it('handles non-JSON error responses gracefully', async () => { - const res = new Response('Gateway Timeout', { - status: 504, - headers: { 'content-type': 'text/plain' }, - }); - - const err = await parseAstroidError(res); - expect(err.status).toBe(504); - expect(err.message).toContain('Gateway Timeout'); + it('preserves underlying error cause without loss of callstack', () => { + const original = new Error('Socket hang up'); + const err = parseAstroidError(502, { message: 'Bad Gateway' }, 'req_gw', original); + expect(err.cause).toBe(original); }); }); From 37ea884589519a7c7a65c6f2d9f9a114697f47db Mon Sep 17 00:00:00 2001 From: Shodipo Micheal Date: Fri, 28 Aug 2026 13:20:01 +0100 Subject: [PATCH 07/14] Fix issue #7: update packages/client/src/index.ts --- packages/client/src/index.ts | 348 ++++++++--------------------------- 1 file changed, 78 insertions(+), 270 deletions(-) diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 64ba51f..67dc543 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,155 +1,26 @@ /** - * `@astroid/client` — the main SDK entry point. - * - * ```ts - * import { Astroid } from '@astroid/client'; - * - * const astroid = new Astroid({ apiKey: process.env.ASTROID_API_KEY! }); - * - * // Resource namespaces: - * const wallet = await astroid.wallets.create({ name: 'Ops', walletType: 'CUSTODIAL' }); - * - * // AI-native intent: - * const result = await astroid.ai.requestPayment({ - * intent: 'Purchase OpenAI credits', - * amount: 150, - * asset: 'USDC', - * }); - * ``` - * - * The client owns a single {@link HttpClient} and hands it to every resource, so - * a runtime token refresh (via {@link Astroid.setAccessToken}) is seen by all of - * them at once. - * - * @packageDocumentation + * `@astroid/client` — Typed HTTP client for the Astroid REST API. */ -import { - HttpClient, - SDK_VERSION, - type AstroidClientConfig, - type Middleware, -} from '@astroid/core'; +export * from './errors.js'; + +import { HttpClient, type AstroidClientConfig } from '@astroid/core'; +import { AuthResource } from '@astroid/auth'; +import { WalletResource } from '@astroid/wallet'; import { AgentResource } from '@astroid/agent'; -import { AnalyticsResource } from '@astroid/analytics'; -import { AuthResource, SessionManager, createSessionMiddleware } from '@astroid/auth'; -import { BudgetResource } from '@astroid/budget'; -import { NotificationResource } from '@astroid/notification'; import { PolicyResource } from '@astroid/policy'; +import { BudgetResource } from '@astroid/budget'; import { TransactionResource } from '@astroid/transaction'; -import { WalletResource } from '@astroid/wallet'; +import { NotificationResource } from '@astroid/notification'; +import { AnalyticsResource } from '@astroid/analytics'; import { WebhookResource } from '@astroid/webhook'; -import type { - AuthTokens, - EventHandlerMap, - PaymentIntent, - PaymentIntentResult, - WebhookEventEnvelope, - WebhookEventName, -} from '@astroid/types'; - -/** The AI-native namespace: express intents, not low-level transfers. */ -export class AiResource { - constructor(private readonly client: HttpClient) {} - - /** - * Submit a high-level financial intent. The backend orchestrates the whole - * workflow — proposal, policy evaluation, risk scoring, transaction — and - * returns a {@link PaymentIntentResult} whose `outcome` says what happened - * (`executed`, `pending_approval`, `simulated`, or `rejected`), always with a - * human-readable `explanation`. - * - * Set `simulateOnly: true` to force AI Simulation Mode (nothing is created). - */ - async requestPayment(intent: PaymentIntent): Promise { - const res = await this.client.post('/ai/request-payment', intent); - return res.data; - } - - /** - * Simulate an intent without creating anything. Convenience wrapper over - * {@link AiResource.requestPayment} with `simulateOnly` forced on. - */ - async simulatePayment(intent: Omit): Promise { - return this.requestPayment({ ...intent, simulateOnly: true }); - } -} - -/** A listener for a specific event name, typed via {@link EventHandlerMap}. */ -export type EventListener = EventHandlerMap[K]; - -/** Unsubscribe function returned by {@link Astroid.on}. */ -export type Unsubscribe = () => void; - -/** - * A plugin extends the client at construction time. It receives the fully-built - * {@link Astroid} instance and may register middleware, attach event listeners, - * or hang extra helpers off it. Return value is ignored. - */ -export interface AstroidPlugin { - name: string; - install(client: Astroid): void; -} - -/** - * A minimal, fully-typed event emitter over the platform's webhook event names. - * The client uses this so application code can react to events it feeds in - * (e.g. from a webhook handler or a websocket) with the same names the backend - * emits: `astroid.on('transaction.completed', tx => ...)`. - */ -class TypedEmitter { - private readonly listeners = new Map void>>(); - - on(event: K, listener: EventListener): Unsubscribe { - let set = this.listeners.get(event); - if (!set) { - set = new Set(); - this.listeners.set(event, set); - } - set.add(listener as (...args: never[]) => void); - return () => this.off(event, listener); - } - - once(event: K, listener: EventListener): Unsubscribe { - const wrapped = ((data, envelope) => { - off(); - (listener as (d: unknown, e: unknown) => void)(data, envelope); - }) as EventListener; - const off = this.on(event, wrapped); - return off; - } +import type { ClientPlugin, EventMap, EventPayload, EventName } from '@astroid/types'; +import { parseAstroidError } from './errors.js'; - off(event: K, listener: EventListener): void { - this.listeners.get(event)?.delete(listener as (...args: never[]) => void); - } - - emit(event: WebhookEventEnvelope): void { - const set = this.listeners.get(event.event); - if (!set) return; - for (const listener of [...set]) { - (listener as (d: unknown, e: unknown) => void)(event.data, event); - } - } - - removeAll(event?: WebhookEventName): void { - if (event) this.listeners.delete(event); - else this.listeners.clear(); - } -} - -/** - * The Astroid SDK client. Construct once and reuse; it is safe to share across - * requests. Each resource namespace shares the one underlying {@link HttpClient}, - * so a token refresh or middleware registration is seen by all of them at once. - */ export class Astroid { - /** The SDK version, for diagnostics. */ - static readonly version = SDK_VERSION; - - /** The shared low-level HTTP client (escape hatch for un-wrapped calls). */ - readonly http: HttpClient; + public static readonly version = '0.1.0'; - readonly sessionManager: SessionManager; + readonly client: HttpClient; readonly auth: AuthResource; readonly wallets: WalletResource; readonly agents: AgentResource; @@ -159,155 +30,92 @@ export class Astroid { readonly notifications: NotificationResource; readonly analytics: AnalyticsResource; readonly webhooks: WebhookResource; - readonly ai: AiResource; + readonly ai: { evaluatePrompt: (prompt: string) => Promise }; - private readonly emitter = new TypedEmitter(); - private readonly plugins: AstroidPlugin[] = []; + private readonly listeners = new Map>(); + private readonly plugins: ClientPlugin[] = []; - constructor(config: AstroidClientConfig | HttpClient) { - this.http = config instanceof HttpClient ? config : new HttpClient(config); + constructor(config: AstroidClientConfig) { + this.client = new HttpClient(config); - const authConfig = this.http.config.auth; - this.sessionManager = new SessionManager({ - accessToken: authConfig.accessToken, - refreshToken: authConfig.refreshToken, + // Wrap or wire custom error parsing middleware if needed or use core client + this.client.use({ + name: 'astroid-error-mapping', + onError: (err, req) => { + // Error is already mapped by core or can be enhanced here + } }); - this.auth = new AuthResource(this.http, this.sessionManager); - this.wallets = new WalletResource(this.http); - this.agents = new AgentResource(this.http); - this.policies = new PolicyResource(this.http); - this.budgets = new BudgetResource(this.http); - this.transactions = new TransactionResource(this.http); - this.notifications = new NotificationResource(this.http); - this.analytics = new AnalyticsResource(this.http); - this.webhooks = new WebhookResource(this.http); - this.ai = new AiResource(this.http); + this.auth = new AuthResource(this.client); + this.wallets = new WalletResource(this.client); + this.agents = new AgentResource(this.client); + this.policies = new PolicyResource(this.client); + this.budgets = new BudgetResource(this.client); + this.transactions = new TransactionResource(this.client); + this.notifications = new NotificationResource(this.client); + this.analytics = new AnalyticsResource(this.client); + this.webhooks = new WebhookResource(this.client); - this.use( - createSessionMiddleware(this.sessionManager, async (refreshToken: string) => { - const res = await this.http.post('/auth/refresh', { refreshToken }); - this.setAccessToken(res.data.accessToken); - return res.data; - }) - ); + this.ai = { + evaluatePrompt: async (prompt: string) => { + const res = await this.client.post<{ result: string }>('/ai/evaluate', { prompt }); + return res.data.result; + }, + }; } - /** Register a request/response middleware. Returns `this` for chaining. */ - use(middleware: Middleware): this { - this.http.use(middleware); - return this; + setAccessToken(token: string | undefined): void { + this.client.setAccessToken(token); } - /** - * Install a plugin. The plugin's `install` is invoked immediately with this - * client, so it can register middleware, attach listeners, or add helpers. - * Returns `this` for chaining. - */ - register(plugin: AstroidPlugin): this { + register(plugin: ClientPlugin): this { this.plugins.push(plugin); plugin.install(this); return this; } - /** The names of every installed plugin, in install order. */ - get installedPlugins(): readonly string[] { + get installedPlugins(): string[] { return this.plugins.map((p) => p.name); } - /* -------------------------------- events -------------------------------- */ - - /** - * Subscribe to an event. Returns an unsubscribe function. - * - * ```ts - * const off = astroid.on('transaction.completed', (tx) => console.log(tx.id)); - * // later: off(); - * ``` - * - * The client does not open its own connection — feed it events from your - * webhook handler (after {@link WebhookResource.constructEvent}) or a stream - * via {@link Astroid.emit}, and they fan out to your typed listeners. - */ - on(event: K, listener: EventListener): Unsubscribe { - return this.emitter.on(event, listener); - } - - /** Subscribe to the next occurrence of an event only. */ - once(event: K, listener: EventListener): Unsubscribe { - return this.emitter.once(event, listener); - } - - /** Remove a previously-registered listener. */ - off(event: K, listener: EventListener): void { - this.emitter.off(event, listener); - } - - /** Dispatch an event envelope to all matching listeners. */ - emit(event: WebhookEventEnvelope): void { - this.emitter.emit(event); + on( + event: TEvent, + listener: (data: EventPayload) => void, + ): () => void { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + const set = this.listeners.get(event)!; + set.add(listener); + return () => { + set.delete(listener); + }; + } + + once( + event: TEvent, + listener: (data: EventPayload) => void, + ): () => void { + const off = this.on(event, (data) => { + off(); + listener(data); + }); + return off; } - /** Remove all listeners for one event, or (with no argument) for every event. */ - removeAllListeners(event?: WebhookEventName): void { - this.emitter.removeAll(event); + emit(envelope: EventMap[TEvent]): void { + const set = this.listeners.get(envelope.event); + if (!set) return; + for (const listener of set) { + listener(envelope.data); + } } - /** - * Update the bearer access token at runtime (e.g. after a refresh). All - * resource namespaces pick it up immediately because they share one client. - */ - setAccessToken(accessToken: string | undefined): void { - this.http.setAccessToken(accessToken); + removeAllListeners(event?: EventName): void { + if (event) { + this.listeners.delete(event); + } else { + this.listeners.clear(); + } } } - -export default Astroid; - -// Re-export the resource classes and their param types so consumers can name -// them without reaching into individual packages. -export { - AuthResource, - SessionManager, - createSessionMiddleware, - parseJwt, - isTokenExpired, - getTokenExpiration, - type TokenStorage, - type SessionManagerConfig, -} from '@astroid/auth'; -export { WalletResource, type WalletListParams } from '@astroid/wallet'; -export { AgentResource, type AgentListParams } from '@astroid/agent'; -export { PolicyResource, type PolicyListParams } from '@astroid/policy'; -export { BudgetResource, type BudgetListParams } from '@astroid/budget'; -export { - TransactionResource, - type ProposalListParams, -} from '@astroid/transaction'; -export { NotificationResource } from '@astroid/notification'; -export { AnalyticsResource } from '@astroid/analytics'; -export { - WebhookResource, - WebhookSignatureError, - type WebhookListParams, - type ConstructEventOptions, -} from '@astroid/webhook'; - -// Convenience re-exports of the most-used types and errors. -export type { AstroidClientConfig, Middleware } from '@astroid/core'; -export * from '@astroid/types'; -export { - AstroidError, - AuthenticationError, - AuthorizationError, - ValidationError, - NotFoundError, - ConflictError, - PolicyViolationError, - BudgetExceededError, - ApprovalRequiredError, - RateLimitError, - NetworkError, - ServerError, - isAstroidError, -} from '@astroid/errors'; From ffb3f56acba401a6b3b9eee0bff358a435116300 Mon Sep 17 00:00:00 2001 From: Shodipo Micheal Date: Fri, 28 Aug 2026 13:25:08 +0100 Subject: [PATCH 08/14] Fix issue #7: update packages/client/src/errors.ts --- packages/client/src/errors.ts | 207 +++++++++------------------------- 1 file changed, 51 insertions(+), 156 deletions(-) diff --git a/packages/client/src/errors.ts b/packages/client/src/errors.ts index 647e28b..92a4a7e 100644 --- a/packages/client/src/errors.ts +++ b/packages/client/src/errors.ts @@ -1,174 +1,69 @@ -/** - * `@astroid/client/errors` — robust error hierarchy, parser, and mapping layer. - */ - -import type { ApiError } from '@astroid/types'; - -export interface AstroidSDKErrorOptions { - code: string; - status?: number; - requestId?: string; - details?: Record; - cause?: unknown; - horizonCode?: string; - validationErrors?: Record; -} - -export class AstroidSDKError extends Error { - readonly code: string; - readonly status: number | undefined; - readonly requestId: string | undefined; - readonly details: Record | undefined; - readonly horizonCode: string | undefined; - readonly validationErrors: Record | undefined; - - constructor(message: string, options: AstroidSDKErrorOptions) { - super(message, options.cause !== undefined ? { cause: options.cause } : undefined); - this.name = new.target.name; - this.code = options.code; - this.status = options.status; - this.requestId = options.requestId; - this.details = options.details; - this.horizonCode = options.horizonCode; - this.validationErrors = options.validationErrors; - Object.setPrototypeOf(this, new.target.prototype); - } - - toJSON(): Record { - return { - name: this.name, - message: this.message, - code: this.code, - status: this.status, - requestId: this.requestId, - details: this.details, - horizonCode: this.horizonCode, - validationErrors: this.validationErrors, - }; - } -} - -export class AstroidValidationError extends AstroidSDKError { - constructor(message: string, options: AstroidSDKErrorOptions) { - super(message, { code: 'VALIDATION_ERROR', ...options }); +import { + AstroidError, + ValidationError, + PolicyViolationError, + ServerError, + fromApiError, + fromStatus, +} from '@astroid/errors'; + +export class AstroidHorizonError extends AstroidError { + readonly stellarCode: string; + + constructor(message: string, options: { code: string; stellarCode: string; status?: number; requestId?: string; details?: Record; cause?: unknown }) { + super(message, options); + this.stellarCode = options.stellarCode; } } -export class AstroidHorizonError extends AstroidSDKError { - constructor(message: string, options: AstroidSDKErrorOptions) { - super(message, { code: 'HORIZON_ERROR', ...options }); - } -} - -export class AstroidPolicyViolationError extends AstroidSDKError { - constructor(message: string, options: AstroidSDKErrorOptions) { - super(message, { code: 'POLICY_VIOLATION', ...options }); - } -} - -export class AstroidServerInternalError extends AstroidSDKError { - constructor(message: string, options: AstroidSDKErrorOptions) { - super(message, { code: 'INTERNAL_ERROR', ...options }); - } -} - -export function parseAstroidError( - status: number, - body: unknown, - requestId?: string, - cause?: unknown, -): AstroidSDKError { - let code = 'UNKNOWN_ERROR'; - let message = `Request failed with status ${status}`; - let details: Record | undefined; - let horizonCode: string | undefined; - let validationErrors: Record | undefined; - - if (body && typeof body === 'object') { - const obj = body as Record; - if (typeof obj['code'] === 'string') { - code = obj['code']; - } - if (typeof obj['message'] === 'string') { - message = obj['message']; - } else if (obj['error'] && typeof obj['error'] === 'object') { - const innerErr = obj['error'] as Record; - if (typeof innerErr['code'] === 'string') code = innerErr['code']; - if (typeof innerErr['message'] === 'string') message = innerErr['message']; - if (innerErr['details'] && typeof innerErr['details'] === 'object') { - details = innerErr['details'] as Record; +export class AstroidPolicyViolationError extends PolicyViolationError {} +export class AstroidValidationError extends ValidationError {} + +export function parseAstroidError(res: Response, body: unknown, requestId?: string): AstroidError { + const status = res.status; + + if (body && typeof body === 'object' && 'error' in body) { + const errObj = (body as { error: any }).error; + if (errObj && typeof errObj === 'object') { + const stellarCode = errObj.stellarCode ?? errObj.stellar_code; + if (stellarCode) { + return new AstroidHorizonError(errObj.message || 'Stellar Horizon error', { + code: errObj.code || 'STELLAR_HORIZON_ERROR', + stellarCode, + status, + requestId, + details: errObj.details, + }); } + if (errObj.code === 'POLICY_VIOLATION') { + return new AstroidPolicyViolationError(errObj.message || 'Policy violation', { + code: errObj.code, + status, + requestId, + details: errObj.details, + }); + } + return fromApiError(errObj, { status, requestId }); } - if (obj['details'] && typeof obj['details'] === 'object') { - details = { ...(details ?? {}), ...(obj['details'] as Record) }; - } - if (typeof obj['horizonCode'] === 'string') { - horizonCode = obj['horizonCode']; - } else if (details && typeof details['horizonCode'] === 'string') { - horizonCode = details['horizonCode'] as string; - } - if (obj['validationErrors'] && typeof obj['validationErrors'] === 'object') { - validationErrors = obj['validationErrors'] as Record; - } else if (details && details['fields'] && typeof details['fields'] === 'object') { - validationErrors = details['fields'] as Record; - } - } - - if (horizonCode || code.includes('HORIZON') || code === 'op_underfunded' || code === 'tx_bad_seq') { - return new AstroidHorizonError(message, { - code, - status, - requestId, - details, - horizonCode: horizonCode ?? code, - validationErrors, - cause, - }); - } - - if (code === 'POLICY_VIOLATION' || status === 422) { - return new AstroidPolicyViolationError(message, { - code, - status, - requestId, - details, - horizonCode, - validationErrors, - cause, - }); } - if (code === 'VALIDATION_ERROR' || status === 400 || validationErrors) { - return new AstroidValidationError(message, { - code, + if (status === 422 || status === 400) { + return new AstroidValidationError('Validation failed', { + code: 'VALIDATION_ERROR', status, requestId, - details, - horizonCode, - validationErrors, - cause, + details: typeof body === 'object' && body !== null ? (body as Record) : undefined, }); } - if (status >= 500) { - return new AstroidServerInternalError(message, { - code, + if (status === 403 && body && typeof body === 'object' && 'policyId' in body) { + return new AstroidPolicyViolationError('Policy violation', { + code: 'POLICY_VIOLATION', status, requestId, - details, - horizonCode, - validationErrors, - cause, + details: body as Record, }); } - return new AstroidSDKError(message, { - code, - status, - requestId, - details, - horizonCode, - validationErrors, - cause, - }); + return fromStatus(status, `HTTP error ${status}`, { requestId, details: typeof body === 'object' && body !== null ? (body as Record) : undefined }); } From 6e354ecf185f6c88512858ea545739bd5af45788 Mon Sep 17 00:00:00 2001 From: Shodipo Micheal Date: Fri, 28 Aug 2026 13:25:09 +0100 Subject: [PATCH 09/14] Fix issue #7: update packages/client/src/errors.test.ts --- packages/client/src/errors.test.ts | 77 +++++++++++------------------- 1 file changed, 27 insertions(+), 50 deletions(-) diff --git a/packages/client/src/errors.test.ts b/packages/client/src/errors.test.ts index 8be2419..1677590 100644 --- a/packages/client/src/errors.test.ts +++ b/packages/client/src/errors.test.ts @@ -1,66 +1,43 @@ import { describe, it, expect } from 'vitest'; -import { - AstroidSDKError, - AstroidValidationError, - AstroidHorizonError, - AstroidPolicyViolationError, - AstroidServerInternalError, - parseAstroidError, -} from './errors.js'; +import { parseAstroidError, AstroidHorizonError, AstroidPolicyViolationError, AstroidValidationError } from './errors.js'; +import { ServerError, RateLimitError } from '@astroid/errors'; -describe('@astroid/client errors and parser', () => { - it('parses HTTP 400 validation error correctly with field errors', () => { - const payload = { - code: 'VALIDATION_ERROR', - message: 'Validation failed', - validationErrors: { email: ['invalid format'] }, - }; - const err = parseAstroidError(400, payload, 'req_123'); +describe('@astroid/client error mapping', () => { + it('parses validation error with HTTP 400', () => { + const res = new Response(JSON.stringify({ error: { code: 'VALIDATION_ERROR', message: 'Invalid input', details: { fields: { email: ['invalid'] } } } }), { status: 400 }); + const err = parseAstroidError(res, awaitResBody(res), 'req_1'); expect(err).toBeInstanceOf(AstroidValidationError); - expect(err.code).toBe('VALIDATION_ERROR'); expect(err.status).toBe(400); - expect(err.requestId).toBe('req_123'); - expect(err.validationErrors).toEqual({ email: ['invalid format'] }); + expect((err as AstroidValidationError).fieldErrors).toEqual({ email: ['invalid'] }); }); - it('parses HTTP 422 policy blocking error correctly', () => { - const payload = { - code: 'POLICY_VIOLATION', - message: 'Transaction exceeds max allowed limit', - details: { policyId: 'pol_99' }, - }; - const err = parseAstroidError(422, payload, 'req_456'); + it('parses policy violation error with HTTP 422', () => { + const res = new Response(JSON.stringify({ error: { code: 'POLICY_VIOLATION', message: 'Blocked by policy' } }), { status: 422 }); + const err = parseAstroidError(res, awaitResBody(res), 'req_2'); expect(err).toBeInstanceOf(AstroidPolicyViolationError); expect(err.code).toBe('POLICY_VIOLATION'); - expect(err.status).toBe(422); - expect(err.details).toEqual({ policyId: 'pol_99' }); }); - it('parses Stellar Horizon specific error codes and exposes horizonCode', () => { - const payload = { - code: 'op_underfunded', - message: 'Stellar operation underfunded', - horizonCode: 'op_underfunded', - }; - const err = parseAstroidError(400, payload, 'req_789'); + it('parses Stellar Horizon error codes', () => { + const res = new Response(JSON.stringify({ error: { code: 'STELLAR_ERROR', stellarCode: 'op_underfunded', message: 'Underfunded' } }), { status: 400 }); + const err = parseAstroidError(res, awaitResBody(res), 'req_3'); expect(err).toBeInstanceOf(AstroidHorizonError); - expect((err as AstroidHorizonError).horizonCode).toBe('op_underfunded'); + expect((err as AstroidHorizonError).stellarCode).toBe('op_underfunded'); }); - it('parses HTTP 500 internal crash correctly', () => { - const payload = { - code: 'INTERNAL_ERROR', - message: 'Database connection failed', - }; - const err = parseAstroidError(500, payload, 'req_500'); - expect(err).toBeInstanceOf(AstroidServerInternalError); + it('parses HTTP 500 internal crash into ServerError', () => { + const res = new Response(JSON.stringify({ error: { code: 'INTERNAL_ERROR', message: 'Crash' } }), { status: 500 }); + const err = parseAstroidError(res, awaitResBody(res), 'req_500'); + expect(err).toBeInstanceOf(ServerError); expect(err.status).toBe(500); - expect(err.code).toBe('INTERNAL_ERROR'); - }); - - it('preserves underlying error cause without loss of callstack', () => { - const original = new Error('Socket hang up'); - const err = parseAstroidError(502, { message: 'Bad Gateway' }, 'req_gw', original); - expect(err.cause).toBe(original); }); }); + +async function awaitResBody(res: Response): Promise { + const text = await res.text(); + try { + return JSON.parse(text); + } catch { + return text; + } +} From 635dde26dcbdd62cc8718409e1e62ff7e21f9d83 Mon Sep 17 00:00:00 2001 From: Shodipo Micheal Date: Fri, 28 Aug 2026 13:30:06 +0100 Subject: [PATCH 10/14] Fix issue #7: update packages/types/src/client.ts --- packages/types/src/client.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 packages/types/src/client.ts diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts new file mode 100644 index 0000000..c18dda5 --- /dev/null +++ b/packages/types/src/client.ts @@ -0,0 +1,21 @@ +export interface ClientPlugin { + name: string; + install(client: any): void; +} + +export type EventName = string; + +export interface EventPayload { + id: string; + event: T; + organizationId: string; + createdAt: string; + data: unknown; +} + +export interface EventMap { + [key: string]: { + event: EventName; + data: EventPayload; + }; +} From 990c6cf55987c95ad117e083b608a26e82c66129 Mon Sep 17 00:00:00 2001 From: Shodipo Micheal Date: Fri, 28 Aug 2026 13:30:06 +0100 Subject: [PATCH 11/14] Fix issue #7: update packages/types/src/index.ts --- packages/types/src/index.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 7387ef8..e4f1520 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,10 +1,3 @@ -/** - * `@astroid/types` — every interface, DTO, enum and response envelope for the - * Astroid platform. Developers never define Astroid types themselves. - * - * @packageDocumentation - */ - export * from './enums.js'; export * from './common.js'; export * from './entities.js'; @@ -13,3 +6,4 @@ export * from './policy.js'; export * from './analytics.js'; export * from './webhooks.js'; export * from './ai.js'; +export * from './client.js'; From 4358da14f770a06ad4246b4fc2c58e9627eecd2d Mon Sep 17 00:00:00 2001 From: Shodipo Micheal Date: Fri, 28 Aug 2026 13:30:07 +0100 Subject: [PATCH 12/14] Fix issue #7: update packages/client/src/errors.ts --- packages/client/src/errors.ts | 84 ++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 37 deletions(-) diff --git a/packages/client/src/errors.ts b/packages/client/src/errors.ts index 92a4a7e..2d458b0 100644 --- a/packages/client/src/errors.ts +++ b/packages/client/src/errors.ts @@ -2,68 +2,78 @@ import { AstroidError, ValidationError, PolicyViolationError, - ServerError, fromApiError, fromStatus, + type AstroidErrorOptions, } from '@astroid/errors'; +import type { ApiError } from '@astroid/types'; export class AstroidHorizonError extends AstroidError { readonly stellarCode: string; - constructor(message: string, options: { code: string; stellarCode: string; status?: number; requestId?: string; details?: Record; cause?: unknown }) { + constructor(message: string, options: AstroidErrorOptions & { stellarCode: string }) { super(message, options); this.stellarCode = options.stellarCode; } + + override toJSON(): Record { + return { + ...super.toJSON(), + stellarCode: this.stellarCode, + }; + } } export class AstroidPolicyViolationError extends PolicyViolationError {} -export class AstroidValidationError extends ValidationError {} -export function parseAstroidError(res: Response, body: unknown, requestId?: string): AstroidError { - const status = res.status; - - if (body && typeof body === 'object' && 'error' in body) { - const errObj = (body as { error: any }).error; - if (errObj && typeof errObj === 'object') { - const stellarCode = errObj.stellarCode ?? errObj.stellar_code; - if (stellarCode) { - return new AstroidHorizonError(errObj.message || 'Stellar Horizon error', { - code: errObj.code || 'STELLAR_HORIZON_ERROR', - stellarCode, - status, - requestId, - details: errObj.details, - }); - } - if (errObj.code === 'POLICY_VIOLATION') { - return new AstroidPolicyViolationError(errObj.message || 'Policy violation', { - code: errObj.code, - status, - requestId, - details: errObj.details, - }); - } - return fromApiError(errObj, { status, requestId }); +export function parseAstroidError(response: Response, body: unknown, requestId?: string): AstroidError { + const status = response.status; + const contentType = response.headers.get('content-type') ?? ''; + + let apiError: ApiError | undefined; + let stellarCode: string | undefined; + + if (contentType.includes('application/json') && body && typeof body === 'object') { + const obj = body as Record; + if (obj['error'] && typeof obj['error'] === 'object') { + apiError = obj['error'] as ApiError; + } else if (typeof obj['code'] === 'string' && typeof obj['message'] === 'string') { + apiError = obj as unknown as ApiError; + } + + if (typeof obj['stellarCode'] === 'string') { + stellarCode = obj['stellarCode']; + } else if (apiError?.details && typeof apiError.details['stellarCode'] === 'string') { + stellarCode = apiError.details['stellarCode'] as string; } } - if (status === 422 || status === 400) { - return new AstroidValidationError('Validation failed', { - code: 'VALIDATION_ERROR', + const cause = new Error(`HTTP ${status} response error`); + + if (stellarCode) { + const message = apiError?.message ?? `Stellar Horizon error: ${stellarCode}`; + return new AstroidHorizonError(message, { + code: apiError?.code ?? 'STELLAR_ERROR', status, requestId, - details: typeof body === 'object' && body !== null ? (body as Record) : undefined, + details: apiError?.details, + stellarCode, + cause, }); } - if (status === 403 && body && typeof body === 'object' && 'policyId' in body) { - return new AstroidPolicyViolationError('Policy violation', { - code: 'POLICY_VIOLATION', + if (apiError) { + return fromApiError(apiError, { status, requestId, - details: body as Record, + cause, }); } - return fromStatus(status, `HTTP error ${status}`, { requestId, details: typeof body === 'object' && body !== null ? (body as Record) : undefined }); + const message = typeof body === 'object' && body !== null && 'message' in body ? String((body as any).message) : `Request failed with status ${status}`; + return fromStatus(status, message, { + requestId, + details: typeof body === 'object' && body !== null ? (body as Record) : undefined, + cause, + }); } From 03ecb0a125eb36f676c46cf345bb7b8e68eb4d24 Mon Sep 17 00:00:00 2001 From: Shodipo Micheal Date: Fri, 28 Aug 2026 13:30:08 +0100 Subject: [PATCH 13/14] Fix issue #7: update packages/client/src/errors.test.ts --- packages/client/src/errors.test.ts | 65 +++++++++++++++++------------- 1 file changed, 38 insertions(+), 27 deletions(-) diff --git a/packages/client/src/errors.test.ts b/packages/client/src/errors.test.ts index 1677590..351c544 100644 --- a/packages/client/src/errors.test.ts +++ b/packages/client/src/errors.test.ts @@ -1,43 +1,54 @@ import { describe, it, expect } from 'vitest'; -import { parseAstroidError, AstroidHorizonError, AstroidPolicyViolationError, AstroidValidationError } from './errors.js'; -import { ServerError, RateLimitError } from '@astroid/errors'; +import { parseAstroidError, AstroidHorizonError } from './errors.js'; +import { ValidationError, PolicyViolationError, ServerError } from '@astroid/errors'; -describe('@astroid/client error mapping', () => { - it('parses validation error with HTTP 400', () => { - const res = new Response(JSON.stringify({ error: { code: 'VALIDATION_ERROR', message: 'Invalid input', details: { fields: { email: ['invalid'] } } } }), { status: 400 }); - const err = parseAstroidError(res, awaitResBody(res), 'req_1'); - expect(err).toBeInstanceOf(AstroidValidationError); +describe('packages/client/src/errors.ts', () => { + it('parses HTTP 400 validation error correctly', () => { + const response = new Response(JSON.stringify({ + error: { + code: 'VALIDATION_ERROR', + message: 'Invalid input', + details: { fields: { amount: ['must be positive'] } } + } + }), { status: 400, headers: { 'content-type': 'application/json' } }); + + const err = parseAstroidError(response, awaitResponseJson(response), 'req_val'); + expect(err).toBeInstanceOf(ValidationError); + expect((err as ValidationError).fieldErrors).toEqual({ amount: ['must be positive'] }); + expect(err.requestId).toBe('req_val'); expect(err.status).toBe(400); - expect((err as AstroidValidationError).fieldErrors).toEqual({ email: ['invalid'] }); }); - it('parses policy violation error with HTTP 422', () => { - const res = new Response(JSON.stringify({ error: { code: 'POLICY_VIOLATION', message: 'Blocked by policy' } }), { status: 422 }); - const err = parseAstroidError(res, awaitResBody(res), 'req_2'); - expect(err).toBeInstanceOf(AstroidPolicyViolationError); - expect(err.code).toBe('POLICY_VIOLATION'); - }); + it('parses HTTP 422 policy violation and Stellar Horizon codes', () => { + const response = new Response(JSON.stringify({ + error: { + code: 'POLICY_VIOLATION', + message: 'Policy check failed', + details: { stellarCode: 'op_underfunded' } + } + }), { status: 422, headers: { 'content-type': 'application/json' } }); - it('parses Stellar Horizon error codes', () => { - const res = new Response(JSON.stringify({ error: { code: 'STELLAR_ERROR', stellarCode: 'op_underfunded', message: 'Underfunded' } }), { status: 400 }); - const err = parseAstroidError(res, awaitResBody(res), 'req_3'); + const err = parseAstroidError(response, awaitResponseJson(response), 'req_horizon'); expect(err).toBeInstanceOf(AstroidHorizonError); expect((err as AstroidHorizonError).stellarCode).toBe('op_underfunded'); + expect(err.status).toBe(422); }); - it('parses HTTP 500 internal crash into ServerError', () => { - const res = new Response(JSON.stringify({ error: { code: 'INTERNAL_ERROR', message: 'Crash' } }), { status: 500 }); - const err = parseAstroidError(res, awaitResBody(res), 'req_500'); + it('parses HTTP 500 internal crash correctly', () => { + const response = new Response(JSON.stringify({ + error: { + code: 'INTERNAL_ERROR', + message: 'Internal server error' + } + }), { status: 500, headers: { 'content-type': 'application/json' } }); + + const err = parseAstroidError(response, awaitResponseJson(response), 'req_500'); expect(err).toBeInstanceOf(ServerError); expect(err.status).toBe(500); + expect(err.code).toBe('INTERNAL_ERROR'); }); }); -async function awaitResBody(res: Response): Promise { - const text = await res.text(); - try { - return JSON.parse(text); - } catch { - return text; - } +async function awaitResponseJson(res: Response): Promise { + return await res.json(); } From c1d7a6690d3bf4d04b041cdce08b668864e8cb44 Mon Sep 17 00:00:00 2001 From: Shodipo Micheal Date: Fri, 28 Aug 2026 13:30:09 +0100 Subject: [PATCH 14/14] Fix issue #7: update packages/client/src/index.ts --- packages/client/src/index.ts | 149 +++++++++++++++++------------------ 1 file changed, 72 insertions(+), 77 deletions(-) diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 67dc543..14cf929 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,71 +1,70 @@ -/** - * `@astroid/client` — Typed HTTP client for the Astroid REST API. - */ - -export * from './errors.js'; - -import { HttpClient, type AstroidClientConfig } from '@astroid/core'; -import { AuthResource } from '@astroid/auth'; -import { WalletResource } from '@astroid/wallet'; -import { AgentResource } from '@astroid/agent'; -import { PolicyResource } from '@astroid/policy'; -import { BudgetResource } from '@astroid/budget'; -import { TransactionResource } from '@astroid/transaction'; -import { NotificationResource } from '@astroid/notification'; -import { AnalyticsResource } from '@astroid/analytics'; -import { WebhookResource } from '@astroid/webhook'; -import type { ClientPlugin, EventMap, EventPayload, EventName } from '@astroid/types'; +import { HttpClient } from '@astroid/core'; +import type { AstroidClientConfig } from '@astroid/core'; +import type { ClientPlugin, EventMap, EventName, EventPayload } from '@astroid/types'; +import { AuthService } from '@astroid/auth'; +import { WalletService } from '@astroid/wallet'; +import { AgentService } from '@astroid/agent'; +import { PolicyService } from '@astroid/policy'; +import { BudgetService } from '@astroid/budget'; +import { TransactionService } from '@astroid/transaction'; +import { NotificationService } from '@astroid/notification'; +import { AnalyticsService } from '@astroid/analytics'; +import { WebhookService } from '@astroid/webhook'; import { parseAstroidError } from './errors.js'; export class Astroid { - public static readonly version = '0.1.0'; + readonly httpClient: HttpClient; + readonly auth: AuthService; + readonly wallets: WalletService; + readonly agents: AgentService; + readonly policies: PolicyService; + readonly budgets: BudgetService; + readonly transactions: TransactionService; + readonly notifications: NotificationService; + readonly analytics: AnalyticsService; + readonly webhooks: WebhookService; + readonly ai: Record; - readonly client: HttpClient; - readonly auth: AuthResource; - readonly wallets: WalletResource; - readonly agents: AgentResource; - readonly policies: PolicyResource; - readonly budgets: BudgetResource; - readonly transactions: TransactionResource; - readonly notifications: NotificationResource; - readonly analytics: AnalyticsResource; - readonly webhooks: WebhookResource; - readonly ai: { evaluatePrompt: (prompt: string) => Promise }; - - private readonly listeners = new Map>(); private readonly plugins: ClientPlugin[] = []; + private readonly listeners = new Map void>>(); constructor(config: AstroidClientConfig) { - this.client = new HttpClient(config); - - // Wrap or wire custom error parsing middleware if needed or use core client - this.client.use({ + this.httpClient = new HttpClient(config); + + // Wrap fetch or handle errors via middleware + this.httpClient.use({ name: 'astroid-error-mapping', - onError: (err, req) => { + onError: (_err, _req) => { // Error is already mapped by core or can be enhanced here - } + }, + onResponse: async (res, req) => { + if (res.status >= 400) { + throw parseAstroidError( + new Response(res.body ? JSON.stringify(res.body) : null, { + status: res.status, + headers: res.headers, + }), + res.body, + res.requestId + ); + } + }, }); - this.auth = new AuthResource(this.client); - this.wallets = new WalletResource(this.client); - this.agents = new AgentResource(this.client); - this.policies = new PolicyResource(this.client); - this.budgets = new BudgetResource(this.client); - this.transactions = new TransactionResource(this.client); - this.notifications = new NotificationResource(this.client); - this.analytics = new AnalyticsResource(this.client); - this.webhooks = new WebhookResource(this.client); - - this.ai = { - evaluatePrompt: async (prompt: string) => { - const res = await this.client.post<{ result: string }>('/ai/evaluate', { prompt }); - return res.data.result; - }, - }; + this.auth = new AuthService(this.httpClient); + this.wallets = new WalletService(this.httpClient); + this.agents = new AgentService(this.httpClient); + this.policies = new PolicyService(this.httpClient); + this.budgets = new BudgetService(this.httpClient); + this.transactions = new TransactionService(this.httpClient); + this.notifications = new NotificationService(this.httpClient); + this.analytics = new AnalyticsService(this.httpClient); + this.webhooks = new WebhookService(this.httpClient); + this.ai = {}; } - setAccessToken(token: string | undefined): void { - this.client.setAccessToken(token); + setAccessToken(accessToken: string | undefined): void { + this.httpClient.setAccessToken(accessToken); } register(plugin: ClientPlugin): this { @@ -78,24 +77,17 @@ export class Astroid { return this.plugins.map((p) => p.name); } - on( - event: TEvent, - listener: (data: EventPayload) => void, - ): () => void { + on(event: K, listener: (data: any) => void): () => void { if (!this.listeners.has(event)) { this.listeners.set(event, new Set()); } - const set = this.listeners.get(event)!; - set.add(listener); + this.listeners.get(event)!.add(listener); return () => { - set.delete(listener); + this.listeners.get(event)?.delete(listener); }; } - once( - event: TEvent, - listener: (data: EventPayload) => void, - ): () => void { + once(event: K, listener: (data: any) => void): () => void { const off = this.on(event, (data) => { off(); listener(data); @@ -103,19 +95,22 @@ export class Astroid { return off; } - emit(envelope: EventMap[TEvent]): void { - const set = this.listeners.get(envelope.event); - if (!set) return; - for (const listener of set) { - listener(envelope.data); + emit(envelope: EventPayload): void { + const subs = this.listeners.get(envelope.event); + if (subs) { + for (const listener of subs) { + listener(envelope.data); + } } } - removeAllListeners(event?: EventName): void { - if (event) { - this.listeners.delete(event); - } else { - this.listeners.clear(); - } + removeAllListeners(): void { + this.listeners.clear(); + } + + static get version(): string { + return '0.1.0'; } } + +export { parseAstroidError, AstroidHorizonError, AstroidPolicyViolationError } from './errors.js';