From 75948a36f10465bae72083e891dedd605e232011 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Mon, 2 Feb 2026 10:30:45 -0500 Subject: [PATCH 1/6] feat: include cause and context in API error messages Enhanced handleRequestError() to surface actionable diagnostic info: - Extract and include 'cause' field (truncated to 200 chars) - Include summary of 'context' object (first 3 keys, values truncated) Error messages now show: HTTP 400: DAML_FAILURE - message (cause: actual_cause) [context: key1=val1] This helps debug DAML_FAILURE errors without needing to access logs. --- src/core/http/HttpClient.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/core/http/HttpClient.ts b/src/core/http/HttpClient.ts index f0c0efbe..ad2c814b 100644 --- a/src/core/http/HttpClient.ts +++ b/src/core/http/HttpClient.ts @@ -185,6 +185,10 @@ 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; + const context = typeof data['context'] === 'object' && data['context'] !== null ? data['context'] : undefined; + + // Build error message with all available details let msg = `HTTP ${status}`; if (code) { msg += `: ${code}`; @@ -192,6 +196,37 @@ export class HttpClient { if (message) { msg += ` - ${message}`; } + // Include cause for DAML/Canton errors - this is the actionable info + if (cause) { + // Truncate long causes but preserve the first ~200 chars which usually have the key info + const truncatedCause = cause.length > 200 ? `${cause.substring(0, 200)}...` : cause; + msg += ` (cause: ${truncatedCause})`; + } + // Include context summary if available + if (context) { + const contextObj = context as Record; + const contextKeys = Object.keys(contextObj); + if (contextKeys.length > 0) { + // Show key context fields that help identify the failure location + const contextSummary = contextKeys + .slice(0, 3) + .map((k) => { + const v = contextObj[k]; + let vStr: string; + if (typeof v === 'string') { + vStr = v; + } else if (v === undefined) { + vStr = 'undefined'; + } else { + vStr = JSON.stringify(v); + } + return `${k}=${vStr.substring(0, 50)}`; + }) + .join(', '); + msg += ` [context: ${contextSummary}]`; + } + } + const err = new ApiError(msg, status, error.response?.statusText); err.response = data; return err; From 14414f89b98b3bc23a8ec6259540f0a9d1acceb3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Feb 2026 16:15:21 +0000 Subject: [PATCH 2/6] fix: improve error diagnostics based on PR review feedback - Add Array.isArray check to prevent arrays from being processed as context - Add explicit null handling in context value serialization - Add try/catch for JSON.stringify to handle circular references and BigInt - Extract truncation limits as named constants (CAUSE_TRUNCATE_LENGTH, etc.) - Refactor context formatting into separate methods for better testability - Add comprehensive unit tests for cause/context extraction Co-authored-by: hardlydiff --- src/core/http/HttpClient.ts | 80 +++++--- test/unit/core/http-client.test.ts | 297 +++++++++++++++++++++++++++++ 2 files changed, 355 insertions(+), 22 deletions(-) diff --git a/src/core/http/HttpClient.ts b/src/core/http/HttpClient.ts index ad2c814b..7b936ead 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; @@ -186,7 +191,11 @@ export class HttpClient { 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; - const context = typeof data['context'] === 'object' && data['context'] !== null ? data['context'] : 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}`; @@ -198,31 +207,16 @@ export class HttpClient { } // Include cause for DAML/Canton errors - this is the actionable info if (cause) { - // Truncate long causes but preserve the first ~200 chars which usually have the key info - const truncatedCause = cause.length > 200 ? `${cause.substring(0, 200)}...` : 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 contextObj = context as Record; - const contextKeys = Object.keys(contextObj); - if (contextKeys.length > 0) { - // Show key context fields that help identify the failure location - const contextSummary = contextKeys - .slice(0, 3) - .map((k) => { - const v = contextObj[k]; - let vStr: string; - if (typeof v === 'string') { - vStr = v; - } else if (v === undefined) { - vStr = 'undefined'; - } else { - vStr = JSON.stringify(v); - } - return `${k}=${vStr.substring(0, 50)}`; - }) - .join(', '); + const contextSummary = this.formatContextSummary(context as Record); + if (contextSummary) { msg += ` [context: ${contextSummary}]`; } } @@ -234,6 +228,48 @@ 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); + return `${k}=${vStr.substring(0, HttpClient.CONTEXT_VALUE_TRUNCATE_LENGTH)}`; + }) + .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 { + return JSON.stringify(v); + } 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..95a6321a 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,265 @@ 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', 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; + expect(apiError.message).toContain('longKey=' + 'x'.repeat(50)); + 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\]/ + ); + }); + }); +}); From 9f79b672865c0411e98f89a5997bd11fbafc5940 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Feb 2026 16:19:58 +0000 Subject: [PATCH 3/6] fix: lint issues in http-client tests Co-authored-by: hardlydiff --- test/unit/core/http-client.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/unit/core/http-client.test.ts b/test/unit/core/http-client.test.ts index 95a6321a..b202c3ea 100644 --- a/test/unit/core/http-client.test.ts +++ b/test/unit/core/http-client.test.ts @@ -148,7 +148,7 @@ describe('HttpClient error diagnostics', () => { expect(error).toBeInstanceOf(ApiError); const apiError = error as ApiError; // Should contain truncated cause with ellipsis - expect(apiError.message).toContain('(cause: ' + 'a'.repeat(200) + '...)'); + expect(apiError.message).toContain(`(cause: ${ 'a'.repeat(200) }...)`); // Should not contain the full 250 characters expect(apiError.message).not.toContain('a'.repeat(250)); } @@ -233,7 +233,7 @@ describe('HttpClient error diagnostics', () => { } catch (error) { expect(error).toBeInstanceOf(ApiError); const apiError = error as ApiError; - expect(apiError.message).toContain('longKey=' + 'x'.repeat(50)); + expect(apiError.message).toContain(`longKey=${'x'.repeat(50)}`); expect(apiError.message).not.toContain('x'.repeat(100)); } }); @@ -313,7 +313,7 @@ describe('HttpClient error diagnostics', () => { it('handles non-serializable context values gracefully', async () => { // Create a circular reference const circular: Record = {}; - circular.self = circular; + circular['self'] = circular; const axiosError = createAxiosError(400, { code: 'DAML_FAILURE', From 88250fcbf381b7ec9c5fda9dadff1b969f714674 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 2 Feb 2026 16:21:15 +0000 Subject: [PATCH 4/6] chore: auto-fix linting issues --- src/core/http/HttpClient.ts | 8 ++++---- test/unit/core/http-client.test.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/core/http/HttpClient.ts b/src/core/http/HttpClient.ts index 7b936ead..b89aaf9e 100644 --- a/src/core/http/HttpClient.ts +++ b/src/core/http/HttpClient.ts @@ -229,8 +229,8 @@ export class HttpClient { } /** - * Formats a context object into a summary string for error messages. - * Shows up to MAX_CONTEXT_KEYS keys with truncated values. + * 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); @@ -249,8 +249,8 @@ export class HttpClient { } /** - * Safely converts a context value to a string representation. - * Handles null, undefined, strings, and objects (with circular reference protection). + * 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') { diff --git a/test/unit/core/http-client.test.ts b/test/unit/core/http-client.test.ts index b202c3ea..f76d6819 100644 --- a/test/unit/core/http-client.test.ts +++ b/test/unit/core/http-client.test.ts @@ -148,7 +148,7 @@ describe('HttpClient error diagnostics', () => { expect(error).toBeInstanceOf(ApiError); const apiError = error as ApiError; // Should contain truncated cause with ellipsis - expect(apiError.message).toContain(`(cause: ${ 'a'.repeat(200) }...)`); + expect(apiError.message).toContain(`(cause: ${'a'.repeat(200)}...)`); // Should not contain the full 250 characters expect(apiError.message).not.toContain('a'.repeat(250)); } From 7cba47d8bfd5a394ebd1ca2d47c9b319164fce5a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Feb 2026 16:31:40 +0000 Subject: [PATCH 5/6] Add ellipsis indicator for truncated context values Fix inconsistent truncation behavior where context values were truncated without any indicator while cause values had an ellipsis appended. Now both truncation cases consistently show '...' when values exceed their respective length limits. --- src/core/http/HttpClient.ts | 6 +++++- test/unit/core/http-client.test.ts | 6 ++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/core/http/HttpClient.ts b/src/core/http/HttpClient.ts index b89aaf9e..39e2e265 100644 --- a/src/core/http/HttpClient.ts +++ b/src/core/http/HttpClient.ts @@ -243,7 +243,11 @@ export class HttpClient { .map((k) => { const v = contextObj[k]; const vStr = this.stringifyContextValue(v); - return `${k}=${vStr.substring(0, HttpClient.CONTEXT_VALUE_TRUNCATE_LENGTH)}`; + const truncatedValue = + vStr.length > HttpClient.CONTEXT_VALUE_TRUNCATE_LENGTH + ? `${vStr.substring(0, HttpClient.CONTEXT_VALUE_TRUNCATE_LENGTH)}...` + : vStr; + return `${k}=${truncatedValue}`; }) .join(', '); } diff --git a/test/unit/core/http-client.test.ts b/test/unit/core/http-client.test.ts index f76d6819..5d22cdc1 100644 --- a/test/unit/core/http-client.test.ts +++ b/test/unit/core/http-client.test.ts @@ -218,7 +218,7 @@ describe('HttpClient error diagnostics', () => { } }); - it('truncates long context values at 50 characters', async () => { + it('truncates long context values at 50 characters with ellipsis', async () => { const longValue = 'x'.repeat(100); const axiosError = createAxiosError(400, { code: 'DAML_FAILURE', @@ -233,7 +233,9 @@ describe('HttpClient error diagnostics', () => { } catch (error) { expect(error).toBeInstanceOf(ApiError); const apiError = error as ApiError; - expect(apiError.message).toContain(`longKey=${'x'.repeat(50)}`); + // 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)); } }); From 4da9f1de3ef73d30d68748bd6a7076245e2addb1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Feb 2026 16:40:45 +0000 Subject: [PATCH 6/6] Fix potential TypeError when JSON.stringify returns undefined JSON.stringify can return undefined (not throw) for functions, Symbols, or objects with a toJSON() method that returns undefined. This could cause a TypeError when accessing .length on the result in formatContextSummary. Added nullish coalescing to ensure stringifyContextValue always returns a string. --- src/core/http/HttpClient.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/http/HttpClient.ts b/src/core/http/HttpClient.ts index 39e2e265..eedcf4a0 100644 --- a/src/core/http/HttpClient.ts +++ b/src/core/http/HttpClient.ts @@ -267,7 +267,8 @@ export class HttpClient { return 'undefined'; } try { - return JSON.stringify(v); + // 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]';