diff --git a/src/core/http/HttpClient.ts b/src/core/http/HttpClient.ts index f0c0efbe..eedcf4a0 100644 --- a/src/core/http/HttpClient.ts +++ b/src/core/http/HttpClient.ts @@ -16,6 +16,11 @@ export class HttpClient { private readonly logger: Logger | undefined; private retryConfig: HttpClientRetryConfig = { maxRetries: 3, delayMs: 6000 }; + // Error message formatting constants + private static readonly CAUSE_TRUNCATE_LENGTH = 200; + private static readonly CONTEXT_VALUE_TRUNCATE_LENGTH = 50; + private static readonly MAX_CONTEXT_KEYS = 3; + constructor(logger?: Logger) { this.axiosInstance = axios.create(); this.logger = logger; @@ -185,6 +190,14 @@ export class HttpClient { const data = (error.response?.data ?? {}) as Record; const code = typeof data['code'] === 'string' ? data['code'] : undefined; const message = typeof data['message'] === 'string' ? data['message'] : undefined; + const cause = typeof data['cause'] === 'string' ? data['cause'] : undefined; + // Only accept plain objects for context, not arrays or null + const context = + typeof data['context'] === 'object' && data['context'] !== null && !Array.isArray(data['context']) + ? data['context'] + : undefined; + + // Build error message with all available details let msg = `HTTP ${status}`; if (code) { msg += `: ${code}`; @@ -192,6 +205,22 @@ export class HttpClient { if (message) { msg += ` - ${message}`; } + // Include cause for DAML/Canton errors - this is the actionable info + if (cause) { + const truncatedCause = + cause.length > HttpClient.CAUSE_TRUNCATE_LENGTH + ? `${cause.substring(0, HttpClient.CAUSE_TRUNCATE_LENGTH)}...` + : cause; + msg += ` (cause: ${truncatedCause})`; + } + // Include context summary if available + if (context) { + const contextSummary = this.formatContextSummary(context as Record); + if (contextSummary) { + msg += ` [context: ${contextSummary}]`; + } + } + const err = new ApiError(msg, status, error.response?.statusText); err.response = data; return err; @@ -199,6 +228,53 @@ export class HttpClient { return new NetworkError(`Request failed: ${error instanceof Error ? error.message : String(error)}`); } + /** + * Formats a context object into a summary string for error messages. Shows up to MAX_CONTEXT_KEYS keys with truncated + * values. + */ + private formatContextSummary(contextObj: Record): string | undefined { + const contextKeys = Object.keys(contextObj); + if (contextKeys.length === 0) { + return undefined; + } + + return contextKeys + .slice(0, HttpClient.MAX_CONTEXT_KEYS) + .map((k) => { + const v = contextObj[k]; + const vStr = this.stringifyContextValue(v); + const truncatedValue = + vStr.length > HttpClient.CONTEXT_VALUE_TRUNCATE_LENGTH + ? `${vStr.substring(0, HttpClient.CONTEXT_VALUE_TRUNCATE_LENGTH)}...` + : vStr; + return `${k}=${truncatedValue}`; + }) + .join(', '); + } + + /** + * Safely converts a context value to a string representation. Handles null, undefined, strings, and objects (with + * circular reference protection). + */ + private stringifyContextValue(v: unknown): string { + if (typeof v === 'string') { + return v; + } + if (v === null) { + return 'null'; + } + if (v === undefined) { + return 'undefined'; + } + try { + // JSON.stringify can return undefined for functions, Symbols, or objects with toJSON() returning undefined + return JSON.stringify(v) ?? '[Object]'; + } catch { + // Handle circular references, BigInt, or other non-serializable values + return '[Object]'; + } + } + /** * Determines whether a request error is retryable. Retries on: * diff --git a/test/unit/core/http-client.test.ts b/test/unit/core/http-client.test.ts index 1477282e..5d22cdc1 100644 --- a/test/unit/core/http-client.test.ts +++ b/test/unit/core/http-client.test.ts @@ -1,4 +1,39 @@ +import axios from 'axios'; import { ApiError, NetworkError } from '../../../src/core/errors'; +import { HttpClient } from '../../../src/core/http/HttpClient'; + +// Mock axios for testing error handling +jest.mock('axios', () => { + const actual = jest.requireActual('axios'); + return { + ...actual, + create: jest.fn(() => ({ + get: jest.fn(), + post: jest.fn(), + delete: jest.fn(), + patch: jest.fn(), + defaults: { headers: { common: {} } }, + })), + isAxiosError: actual.isAxiosError, + }; +}); + +// Helper to create a properly structured Axios error +function createAxiosError( + status: number, + data: Record, + statusText = 'Error' +): Error & { isAxiosError: boolean; response: { status: number; statusText: string; data: unknown } } { + const error = new Error('Request failed') as Error & { + isAxiosError: boolean; + response: { status: number; statusText: string; data: unknown }; + }; + error.isAxiosError = true; + error.response = { status, statusText, data }; + // Make axios.isAxiosError return true for this error + Object.defineProperty(error, 'isAxiosError', { value: true }); + return error; +} describe('HttpClient error types', () => { describe('ApiError', () => { @@ -71,3 +106,267 @@ describe('error codes', () => { expect(error.code).toBe('NETWORK_ERROR'); }); }); + +describe('HttpClient error diagnostics', () => { + let httpClient: HttpClient; + let mockAxiosInstance: { get: jest.Mock; post: jest.Mock }; + + beforeEach(() => { + jest.clearAllMocks(); + httpClient = new HttpClient(); + httpClient.setRetryConfig({ maxRetries: 0, delayMs: 0 }); // Disable retries for tests + mockAxiosInstance = (axios.create as jest.Mock).mock.results[0]?.value; + }); + + describe('cause extraction', () => { + it('includes cause in error message when present', async () => { + const axiosError = createAxiosError(400, { + code: 'DAML_FAILURE', + message: 'Contract not found', + cause: 'The referenced contract does not exist in the active contract set', + }); + mockAxiosInstance.get.mockRejectedValueOnce(axiosError); + + await expect(httpClient.makeGetRequest('http://test.com/api')).rejects.toThrow( + /HTTP 400: DAML_FAILURE - Contract not found \(cause: The referenced contract does not exist/ + ); + }); + + it('truncates long cause messages at 200 characters', async () => { + const longCause = 'a'.repeat(250); + const axiosError = createAxiosError(400, { + code: 'DAML_FAILURE', + message: 'Error', + cause: longCause, + }); + mockAxiosInstance.get.mockRejectedValueOnce(axiosError); + + try { + await httpClient.makeGetRequest('http://test.com/api'); + fail('Expected error to be thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiError = error as ApiError; + // Should contain truncated cause with ellipsis + expect(apiError.message).toContain(`(cause: ${'a'.repeat(200)}...)`); + // Should not contain the full 250 characters + expect(apiError.message).not.toContain('a'.repeat(250)); + } + }); + + it('ignores non-string cause values', async () => { + const axiosError = createAxiosError(400, { + code: 'DAML_FAILURE', + message: 'Error', + cause: { nested: 'object' }, + }); + mockAxiosInstance.get.mockRejectedValueOnce(axiosError); + + try { + await httpClient.makeGetRequest('http://test.com/api'); + fail('Expected error to be thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiError = error as ApiError; + expect(apiError.message).not.toContain('cause:'); + } + }); + }); + + describe('context extraction', () => { + it('includes context summary in error message', async () => { + const axiosError = createAxiosError(400, { + code: 'DAML_FAILURE', + message: 'Error', + context: { + contractId: 'abc123', + templateId: 'Module:Template', + }, + }); + mockAxiosInstance.get.mockRejectedValueOnce(axiosError); + + await expect(httpClient.makeGetRequest('http://test.com/api')).rejects.toThrow( + /\[context: contractId=abc123, templateId=Module:Template\]/ + ); + }); + + it('limits context to first 3 keys', async () => { + const axiosError = createAxiosError(400, { + code: 'DAML_FAILURE', + message: 'Error', + context: { + key1: 'value1', + key2: 'value2', + key3: 'value3', + key4: 'value4', + key5: 'value5', + }, + }); + mockAxiosInstance.get.mockRejectedValueOnce(axiosError); + + try { + await httpClient.makeGetRequest('http://test.com/api'); + fail('Expected error to be thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiError = error as ApiError; + expect(apiError.message).toContain('key1=value1'); + expect(apiError.message).toContain('key2=value2'); + expect(apiError.message).toContain('key3=value3'); + expect(apiError.message).not.toContain('key4'); + expect(apiError.message).not.toContain('key5'); + } + }); + + it('truncates long context values at 50 characters with ellipsis', async () => { + const longValue = 'x'.repeat(100); + const axiosError = createAxiosError(400, { + code: 'DAML_FAILURE', + message: 'Error', + context: { longKey: longValue }, + }); + mockAxiosInstance.get.mockRejectedValueOnce(axiosError); + + try { + await httpClient.makeGetRequest('http://test.com/api'); + fail('Expected error to be thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiError = error as ApiError; + // Should contain truncated value with ellipsis + expect(apiError.message).toContain(`longKey=${'x'.repeat(50)}...`); + // Should not contain the full 100 characters + expect(apiError.message).not.toContain('x'.repeat(100)); + } + }); + + it('ignores arrays as context', async () => { + const axiosError = createAxiosError(400, { + code: 'DAML_FAILURE', + message: 'Error', + context: ['item1', 'item2'], + }); + mockAxiosInstance.get.mockRejectedValueOnce(axiosError); + + try { + await httpClient.makeGetRequest('http://test.com/api'); + fail('Expected error to be thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiError = error as ApiError; + expect(apiError.message).not.toContain('[context:'); + } + }); + + it('handles null context values', async () => { + const axiosError = createAxiosError(400, { + code: 'DAML_FAILURE', + message: 'Error', + context: { nullValue: null }, + }); + mockAxiosInstance.get.mockRejectedValueOnce(axiosError); + + try { + await httpClient.makeGetRequest('http://test.com/api'); + fail('Expected error to be thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiError = error as ApiError; + expect(apiError.message).toContain('nullValue=null'); + } + }); + + it('handles undefined context values', async () => { + const axiosError = createAxiosError(400, { + code: 'DAML_FAILURE', + message: 'Error', + context: { undefinedValue: undefined }, + }); + mockAxiosInstance.get.mockRejectedValueOnce(axiosError); + + try { + await httpClient.makeGetRequest('http://test.com/api'); + fail('Expected error to be thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiError = error as ApiError; + expect(apiError.message).toContain('undefinedValue=undefined'); + } + }); + + it('serializes object context values as JSON', async () => { + const axiosError = createAxiosError(400, { + code: 'DAML_FAILURE', + message: 'Error', + context: { nested: { foo: 'bar' } }, + }); + mockAxiosInstance.get.mockRejectedValueOnce(axiosError); + + try { + await httpClient.makeGetRequest('http://test.com/api'); + fail('Expected error to be thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiError = error as ApiError; + expect(apiError.message).toContain('nested={"foo":"bar"}'); + } + }); + + it('handles non-serializable context values gracefully', async () => { + // Create a circular reference + const circular: Record = {}; + circular['self'] = circular; + + const axiosError = createAxiosError(400, { + code: 'DAML_FAILURE', + message: 'Error', + context: { circular }, + }); + mockAxiosInstance.get.mockRejectedValueOnce(axiosError); + + try { + await httpClient.makeGetRequest('http://test.com/api'); + fail('Expected error to be thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiError = error as ApiError; + // Should use fallback [Object] representation + expect(apiError.message).toContain('circular=[Object]'); + } + }); + + it('handles empty context object', async () => { + const axiosError = createAxiosError(400, { + code: 'DAML_FAILURE', + message: 'Error', + context: {}, + }); + mockAxiosInstance.get.mockRejectedValueOnce(axiosError); + + try { + await httpClient.makeGetRequest('http://test.com/api'); + fail('Expected error to be thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiError = error as ApiError; + expect(apiError.message).not.toContain('[context:'); + } + }); + }); + + describe('combined cause and context', () => { + it('includes both cause and context when present', async () => { + const axiosError = createAxiosError(400, { + code: 'DAML_FAILURE', + message: 'Contract not found', + cause: 'The contract was archived', + context: { contractId: 'xyz789' }, + }); + mockAxiosInstance.get.mockRejectedValueOnce(axiosError); + + await expect(httpClient.makeGetRequest('http://test.com/api')).rejects.toThrow( + /HTTP 400: DAML_FAILURE - Contract not found \(cause: The contract was archived\) \[context: contractId=xyz789\]/ + ); + }); + }); +});