diff --git a/src/api-client/base-client.ts b/src/api-client/base-client.ts index e2ed3362..9b5d8f84 100644 --- a/src/api-client/base-client.ts +++ b/src/api-client/base-client.ts @@ -12,7 +12,10 @@ import { ObjectToSnake, ObjectToCamel } from 'ts-case-convert'; -import { HTTPValidationError } from '../types/errors.types'; +import { + GalileoAPIError, + isGalileoAPIStandardErrorData +} from '../types/errors.types'; // Type guards for snake_case and camelCase conversion type ValidatedSnakeCase = @@ -31,17 +34,6 @@ export enum RequestMethod { export const GENERIC_ERROR_MESSAGE = 'This error has been automatically tracked. Please try again.'; -export const parseApiErrorMessage = (error: any) => { - const errorMessage = - typeof error?.detail === 'string' - ? error?.detail - : typeof error?.detail?.[0].msg === 'string' - ? error?.detail?.[0].msg - : GENERIC_ERROR_MESSAGE; - - return errorMessage; -}; - export class BaseClient { protected apiUrl: string = ''; protected token: string = ''; @@ -149,7 +141,6 @@ export class BaseClient { }; const response = await axios.request(config); - this.validateResponse(response); return response; } @@ -194,20 +185,11 @@ export class BaseClient { params, extraHeaders ); + + this.validateAxiosResponse(response); return response.data; } catch (error) { - if (axios.isAxiosError(error)) { - if (error.response) { - this.validateResponse(error.response); - } - - // Throw if validateResponse doesn't identify status code - const errorMessage = error.message || GENERIC_ERROR_MESSAGE; - throw new Error(`Request failed: ${errorMessage}`); - } - - // Re-throw non-axios errors - throw error; + return await this.validateError(error); } } @@ -271,21 +253,21 @@ export class BaseClient { params && 'group_id' in params ? (params.group_id as string) : '' )}`; - const response = await axios.request({ - method: request_method, - url: endpointPath, - params, - headers, - data, - responseType: 'stream' - }); + try { + const response = await axios.request({ + method: request_method, + url: endpointPath, + params, + headers, + data, + responseType: 'stream' + }); - if (response.status >= 300) { - const errorMessage = `Streaming request failed with status ${response.status}`; - throw new Error(errorMessage); + this.validateAxiosResponse(response); + return response.data; + } catch (error) { + return await this.validateError(error); } - - return response.data; } public convertToSnakeCase( @@ -346,59 +328,13 @@ export class BaseClient { ); } - protected processResponse( - data: T | undefined, - error: object | unknown - ): T { - if (error) { - let statusCode: number | undefined; - let errorData: any = error; - - if (typeof error === 'object' && error !== null) { - if ('status' in error && typeof error.status === 'number') { - statusCode = error.status; - } - - if ('data' in error) { - errorData = error.data; - } else if ('response' in error && typeof error.response === 'object') { - const response = error.response as any; - if (response?.status) { - statusCode = response.status; - } - if (response?.data) { - errorData = response.data; - } - } - } - - // Use parseApiErrorMessage for consistent error message formatting - const errorMessage = parseApiErrorMessage(errorData); - - // Format error message similar to validateResponse - if (statusCode) { - const msg = `❗ Something didn't go quite right. The API returned a non-ok status code ${statusCode} with output: ${errorMessage}`; - throw new Error(msg); - } - - throw new Error(`Request failed: ${errorMessage}`); - } - - if (data) { - return data; - } - throw new Error('Request failed: No data received from API'); - } - protected getAuthHeader(token: string): { Authorization: string } { return { Authorization: `Bearer ${token}` }; } - protected validateResponse(response: AxiosResponse): void { + protected validateAxiosResponse(response: AxiosResponse): void { if (response.status >= 300) { - const errorMessage = parseApiErrorMessage(response.data); - const msg = `❗ Something didn't go quite right. The API returned a non-ok status code ${response.status} with output: ${errorMessage}`; - throw new Error(msg); + this.generateApiError(response.data, response.status); } } @@ -421,30 +357,77 @@ export class BaseClient { } } - protected isHTTPValidationError( - error: unknown - ): error is HTTPValidationError { - return typeof error === 'object' && error !== null && 'detail' in error; + private readStreamToString(stream: Readable): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on('data', (chunk: Buffer | string) => + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + ); + stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + stream.on('error', reject); + }); } - protected extractErrorDetail(error: unknown): string { - if (this.isHTTPValidationError(error)) { - const httpError = error as HTTPValidationError; - if (typeof httpError.detail === 'string') { - return httpError.detail; + private async validateError(error: unknown): Promise { + if (axios.isAxiosError(error)) { + if (error.response) { + let data: unknown = error.response.data; + if (data instanceof Readable) { + const raw = await this.readStreamToString(data); + try { + data = JSON.parse(raw); + } catch { + data = { detail: raw }; + } + this.validateAxiosResponse({ + ...error.response, + data + } as AxiosResponse); + } else { + this.validateAxiosResponse(error.response); + } } - // Handle array of validation errors - if (Array.isArray(httpError.detail)) { - return httpError.detail - .map((err) => { - const loc = err.loc ? err.loc.join('.') : 'unknown'; - const msg = err.msg || 'validation error'; - return `${loc}: ${msg}`; - }) - .join('; '); + + const errorMessage = error.message || GENERIC_ERROR_MESSAGE; + throw new Error(`Request failed: ${errorMessage}`); + } + + throw error; + } + + private generateApiError(error: unknown, statusCode?: number): never { + if (error && typeof error === 'object') { + if ('standard_error' in error) { + if (isGalileoAPIStandardErrorData(error.standard_error)) { + throw new GalileoAPIError(error.standard_error); + } else { + throw new Error( + `❗ Something didn't go quite right. The API returned an error, but the details could not be parsed.` + ); + } + } else if ('detail' in error) { + const errorMessage = + typeof error.detail === 'string' + ? error.detail + : Array.isArray(error.detail) && + typeof error.detail[0]?.msg === 'string' + ? error.detail?.[0]?.msg + : GENERIC_ERROR_MESSAGE; + + if (statusCode) { + throw new Error( + `❗ Something didn't go quite right. The API returned a non-ok status code ${statusCode} with output: ${errorMessage}` + ); + } else { + throw new Error( + `❗ Something didn't go quite right. ${errorMessage}` + ); + } + } else { + throw new Error(GENERIC_ERROR_MESSAGE); } - return JSON.stringify(httpError.detail); + } else { + throw new Error(GENERIC_ERROR_MESSAGE); } - return error instanceof Error ? error.message : String(error); } } diff --git a/src/api-client/services/experiment-tags-service.ts b/src/api-client/services/experiment-tags-service.ts index 3c0a6a3d..d91fa940 100644 --- a/src/api-client/services/experiment-tags-service.ts +++ b/src/api-client/services/experiment-tags-service.ts @@ -1,7 +1,6 @@ import { BaseClient, RequestMethod } from '../base-client'; import { Routes } from '../../types/routes.types'; import type { RunTagDB, RunTagDBOpenAPI } from '../../types/experiment.types'; -import { ExperimentTagsAPIException } from '../../utils/errors'; export class ExperimentTagsService extends BaseClient { private projectId: string; @@ -19,32 +18,19 @@ export class ExperimentTagsService extends BaseClient { * @returns A promise that resolves to an array of experiment tags. */ public async getExperimentTags(experimentId: string): Promise { - try { - const response = await this.makeRequest( - RequestMethod.GET, - Routes.experimentTags, - null, - { - project_id: this.projectId, - experiment_id: experimentId - } - ); - - return response.map((item) => - this.convertToCamelCase(item) - ); - } catch (error) { - // Check if it's an HTTPValidationError - if (this.isHTTPValidationError(error)) { - throw new ExperimentTagsAPIException( - `Failed to get experiment tags: ${this.extractErrorDetail(error)}` - ); + const response = await this.makeRequest( + RequestMethod.GET, + Routes.experimentTags, + null, + { + project_id: this.projectId, + experiment_id: experimentId } - // Re-throw as typed exception - throw new ExperimentTagsAPIException( - error instanceof Error ? error.message : String(error) - ); - } + ); + + return response.map((item) => + this.convertToCamelCase(item) + ); } /** @@ -63,64 +49,45 @@ export class ExperimentTagsService extends BaseClient { value: string, tagType: string = 'generic' ): Promise { - try { - // First, check if a tag with this key already exists - const existingTags = await this.getExperimentTags(experimentId); - const existingTag = existingTags.find((tag) => tag.key === key); + // First, check if a tag with this key already exists + const existingTags = await this.getExperimentTags(experimentId); + const existingTag = existingTags.find((tag) => tag.key === key); - if (existingTag) { - // Tag exists - use PUT to update it - const response = await this.makeRequest( - RequestMethod.PUT, - Routes.experimentTag, - { - key, - value, - tag_type: tagType - }, - { - project_id: this.projectId, - experiment_id: experimentId, - tag_id: existingTag.id - } - ); - if (!response) { - throw new ExperimentTagsAPIException('No response received from API'); + if (existingTag) { + // Tag exists - use PUT to update it + const response = await this.makeRequest( + RequestMethod.PUT, + Routes.experimentTag, + { + key, + value, + tag_type: tagType + }, + { + project_id: this.projectId, + experiment_id: experimentId, + tag_id: existingTag.id } + ); - return this.convertToCamelCase(response); - } else { - // Tag doesn't exist - use POST to create it - const response = await this.makeRequest( - RequestMethod.POST, - Routes.experimentTags, - { - key, - value, - tag_type: tagType - }, - { - project_id: this.projectId, - experiment_id: experimentId - } - ); - if (!response) { - throw new ExperimentTagsAPIException('No response received from API'); + return this.convertToCamelCase(response); + } else { + // Tag doesn't exist - use POST to create it + const response = await this.makeRequest( + RequestMethod.POST, + Routes.experimentTags, + { + key, + value, + tag_type: tagType + }, + { + project_id: this.projectId, + experiment_id: experimentId } - return this.convertToCamelCase(response); - } - } catch (error) { - if (error instanceof ExperimentTagsAPIException) { - throw error; - } - if (this.isHTTPValidationError(error)) { - throw new ExperimentTagsAPIException( - `Failed to upsert experiment tag: ${this.extractErrorDetail(error)}` - ); - } - throw new ExperimentTagsAPIException( - error instanceof Error ? error.message : String(error) ); + + return this.convertToCamelCase(response); } } @@ -134,26 +101,15 @@ export class ExperimentTagsService extends BaseClient { experimentId: string, tagId: string ): Promise { - try { - await this.makeRequest( - RequestMethod.DELETE, - Routes.experimentTag, - null, - { - project_id: this.projectId, - experiment_id: experimentId, - tag_id: tagId - } - ); - } catch (error) { - if (this.isHTTPValidationError(error)) { - throw new ExperimentTagsAPIException( - `Failed to delete experiment tag: ${this.extractErrorDetail(error)}` - ); + await this.makeRequest( + RequestMethod.DELETE, + Routes.experimentTag, + null, + { + project_id: this.projectId, + experiment_id: experimentId, + tag_id: tagId } - throw new ExperimentTagsAPIException( - error instanceof Error ? error.message : String(error) - ); - } + ); } } diff --git a/src/index.ts b/src/index.ts index 23870199..1d66be26 100644 --- a/src/index.ts +++ b/src/index.ts @@ -280,7 +280,6 @@ export type { export { APIException, ExperimentAPIException, - ExperimentTagsAPIException, DatasetAPIException, ProjectAPIException } from './utils/errors'; diff --git a/src/types/errors.types.ts b/src/types/errors.types.ts index 1531ea2a..c80d4b3b 100644 --- a/src/types/errors.types.ts +++ b/src/types/errors.types.ts @@ -5,3 +5,137 @@ export interface HTTPValidationError { detail: string | Array<{ loc: string[]; msg: string; type: string }>; } + +/** + * Galileo Standard error data structure from the API. + * This represents the raw error data structure (snake_case from API). + * Used internally for type checking during conversion. + */ +export interface GalileoAPIStandardErrorData { + /** Numeric error code that uniquely identifies the error type */ + error_code: number; + /** Human-readable error type identifier */ + error_type: string; + /** Group/category the error belongs to (e.g., "dataset", "playground", "shared") */ + error_group: string; + /** Severity level (e.g., "low", "medium", "high", "critical") */ + severity: string; + /** Human-readable error message */ + message: string; + /** Suggested action for the user to resolve the error */ + user_action?: string; + /** Optional link to documentation about this error */ + documentation_link?: string | null; + /** Whether the error is retriable (client can retry the request) */ + retriable: boolean; + /** Whether the error is blocking (requires user intervention) */ + blocking: boolean; + /** HTTP status code associated with this error */ + http_status_code?: number; + /** Internal identifier of the service emitting the error (api, runners, ui) */ + source_service?: string | null; + /** Optional context information (e.g., exception_type, exception_message) */ + context?: Record | null; +} + +/** + * Type guard to validate if an object matches the GalileoAPIStandardErrorData interface. + * @param value - The value to validate + * @returns True if the value matches the interface shape, false otherwise + */ +export function isGalileoAPIStandardErrorData( + value: unknown +): value is GalileoAPIStandardErrorData { + if (!value || typeof value !== 'object') { + return false; + } + + const obj = value as Record; + + return ( + typeof obj.error_code === 'number' && + typeof obj.error_type === 'string' && + typeof obj.error_group === 'string' && + typeof obj.severity === 'string' && + typeof obj.message === 'string' && + typeof obj.retriable === 'boolean' && + typeof obj.blocking === 'boolean' && + (obj.user_action === undefined || + obj.user_action === null || + typeof obj.user_action === 'string') && + (obj.documentation_link === undefined || + obj.documentation_link === null || + typeof obj.documentation_link === 'string') && + (obj.http_status_code === undefined || + obj.http_status_code === null || + typeof obj.http_status_code === 'number') && + (obj.source_service === undefined || + obj.source_service === null || + typeof obj.source_service === 'string') && + (obj.context === undefined || + obj.context === null || + (typeof obj.context === 'object' && + obj.context !== null && + !Array.isArray(obj.context))) + ); +} + +/** + * Galileo API Error class for structured error handling. + * Extends Error to provide proper stack traces and error handling while + * preserving all structured error information from the API. + * + * As specified in https://github.com/rungalileo/orbit/blob/main/libs/python/error_management/docs/error_catalog.md + */ +export class GalileoAPIError extends Error { + readonly errorCode: number; + readonly errorType: string; + readonly errorGroup: string; + readonly severity: string; + readonly userAction?: string; + readonly documentationLink?: string | null; + readonly retriable: boolean; + readonly blocking: boolean; + readonly httpStatusCode?: number; + readonly sourceService?: string | null; + readonly context?: Record | null; + + constructor(data: GalileoAPIStandardErrorData) { + super(data.message); + this.name = 'GalileoAPIError'; + this.errorCode = data.error_code; + this.errorType = data.error_type; + this.errorGroup = data.error_group; + this.severity = data.severity; + this.userAction = data.user_action; + this.documentationLink = data.documentation_link ?? null; + this.retriable = data.retriable; + this.blocking = data.blocking; + this.httpStatusCode = data.http_status_code; + this.sourceService = data.source_service ?? null; + this.context = data.context ?? null; + } + + /** + * Serializes the error to JSON format for logging and debugging. + * Includes all error properties including stack trace. + */ + toJSON(): Record { + return { + name: this.name, + message: this.message, + errorCode: this.errorCode, + errorType: this.errorType, + errorGroup: this.errorGroup, + severity: this.severity, + userAction: this.userAction, + documentationLink: this.documentationLink, + retriable: this.retriable, + blocking: this.blocking, + httpStatusCode: this.httpStatusCode, + sourceService: this.sourceService, + context: this.context, + stack: this.stack + }; + } +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts index a42697ab..3399bc10 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -156,16 +156,6 @@ export class ExperimentAPIException extends APIException { } } -/** - * Exception raised when experiment tags operations fail. - */ -export class ExperimentTagsAPIException extends APIException { - constructor(message: string | unknown) { - super(message); - this.name = 'ExperimentTagsAPIException'; - } -} - /** * Exception raised when dataset operations fail. */ diff --git a/src/utils/galileo-logger.ts b/src/utils/galileo-logger.ts index c4872d83..d20dc9fb 100644 --- a/src/utils/galileo-logger.ts +++ b/src/utils/galileo-logger.ts @@ -1518,7 +1518,7 @@ class GalileoLogger implements IGalileoLogger { } } - throw error; + console.error(error); } } @@ -1638,14 +1638,10 @@ class GalileoLogger implements IGalileoLogger { } } - throw error; + console.error(error); } } - // ============================================ - // Private Implementation Methods - // ============================================ - /** * Ensures the Galileo API client is initialized with the current logger's configuration. * @@ -1730,9 +1726,7 @@ class GalileoLogger implements IGalileoLogger { // Increment retry count on each retry attempt this.taskHandler?.incrementRetry(taskId); const retryCount = this.taskHandler?.getRetryCount(taskId) || 0; - console.info( - `Retry #${retryCount} for task ${taskId}: ${error.message}` - ); + console.warn(`Retry #${retryCount} for task ${taskId}: `, error); } ); }) @@ -1754,7 +1748,8 @@ class GalileoLogger implements IGalileoLogger { const currentParent = this.currentParent(); if (!currentParent) { - throw new Error('A trace needs to be created in order to add a span.'); + console.error('A trace needs to be created in order to add a span.'); + return; } // For workflow/agent spans, use previous parent @@ -1764,7 +1759,8 @@ class GalileoLogger implements IGalileoLogger { : currentParent; if (!parentStep) { - throw new Error('A trace needs to be created in order to add a span.'); + console.error('A trace needs to be created in order to add a span.'); + return; } // Use traceId from constructor if provided, otherwise use first trace's id @@ -1797,9 +1793,7 @@ class GalileoLogger implements IGalileoLogger { // Increment retry count on each retry attempt this.taskHandler?.incrementRetry(taskId); const retryCount = this.taskHandler?.getRetryCount(taskId) || 0; - console.info( - `Retry #${retryCount} for task ${taskId}: ${error.message}` - ); + console.warn(`Retry #${retryCount} for task ${taskId}: `, error); } ); }) @@ -1856,7 +1850,7 @@ class GalileoLogger implements IGalileoLogger { // Increment retry count on each retry attempt this.taskHandler?.incrementRetry(taskId); const retryCount = this.taskHandler?.getRetryCount(taskId) || 0; - console.info( + console.warn( `Retry #${retryCount} for task ${taskId}: ${error.message}` ); } @@ -1922,9 +1916,7 @@ class GalileoLogger implements IGalileoLogger { // Increment retry count on each retry attempt this.taskHandler?.incrementRetry(taskId); const retryCount = this.taskHandler?.getRetryCount(taskId) || 0; - console.info( - `Retry #${retryCount} for task ${taskId}: ${error.message}` - ); + console.warn(`Retry #${retryCount} for task ${taskId}: `, error); } ); }, @@ -1960,7 +1952,12 @@ class GalileoLogger implements IGalileoLogger { }): StepWithChildSpans | undefined { const currentParent = this.currentParent(); if (currentParent === undefined) { - throw new Error('No existing workflow to conclude.'); + if (this.mode === 'streaming') { + console.error('No existing workflow to conclude.'); + return; + } else { + throw new Error('No existing workflow to conclude.'); + } } currentParent.output = output || currentParent.output; diff --git a/tests/api-client/base-client.test.ts b/tests/api-client/base-client.test.ts index a7702049..ec64f3aa 100644 --- a/tests/api-client/base-client.test.ts +++ b/tests/api-client/base-client.test.ts @@ -1,8 +1,16 @@ +import { Readable } from 'stream'; +import axios from 'axios'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; -import { BaseClient, RequestMethod } from '../../src/api-client/base-client'; +import { + BaseClient, + GENERIC_ERROR_MESSAGE, + RequestMethod +} from '../../src/api-client/base-client'; import { Routes } from '../../src/types/routes.types'; import { getSdkIdentifier } from '../../src/utils/version'; +import { GalileoAPIError } from '../../src/types/errors.types'; +import type { GalileoAPIStandardErrorData } from '../../src/types/errors.types'; // Test implementation of BaseClient class TestClient extends BaseClient { @@ -16,30 +24,37 @@ class TestClient extends BaseClient { public async testRequest() { return this.makeRequest(RequestMethod.GET, Routes.healthCheck); } -} -describe('BaseClient Headers', () => { - let capturedHeaders: Record = {}; + public setTokenForTest(t: string): void { + this.token = t; + } +} - const server = setupServer( - http.get('http://localhost:8088/healthcheck', ({ request }) => { - // Capture headers from the request - capturedHeaders = {}; - request.headers.forEach((value, key) => { - capturedHeaders[key] = value; - }); +let capturedHeaders: Record = {}; - return HttpResponse.json({ status: 'ok' }); - }) - ); +const getProjectHandler = jest + .fn() + .mockImplementation(() => HttpResponse.json({ id: 'p-1' })); - beforeAll(() => server.listen()); - afterEach(() => { - server.resetHandlers(); +const server = setupServer( + http.get('http://localhost:8088/healthcheck', ({ request }) => { capturedHeaders = {}; - }); - afterAll(() => server.close()); + request.headers.forEach((value, key) => { + capturedHeaders[key] = value; + }); + return HttpResponse.json({ status: 'ok' }); + }), + http.get('http://localhost:8088/projects/p-1', () => getProjectHandler()) +); + +beforeAll(() => server.listen()); +afterEach(() => { + server.resetHandlers(); + capturedHeaders = {}; +}); +afterAll(() => server.close()); +describe('BaseClient Headers', () => { it('should include X-Galileo-SDK header with correct value', async () => { const client = new TestClient(); @@ -62,21 +77,8 @@ describe('BaseClient Headers', () => { }); it('should include custom headers when provided', async () => { - const server = setupServer( - http.get('http://localhost:8088/healthcheck', ({ request }) => { - capturedHeaders = {}; - request.headers.forEach((value, key) => { - capturedHeaders[key] = value; - }); - return HttpResponse.json({ status: 'ok' }); - }) - ); - - server.listen(); - const client = new TestClient(); - // Test with extra headers await client.makeRequest( RequestMethod.GET, Routes.healthCheck, @@ -87,7 +89,389 @@ describe('BaseClient Headers', () => { expect(capturedHeaders['custom-header']).toBe('custom-value'); expect(capturedHeaders['x-galileo-sdk']).toBe(getSdkIdentifier()); + }); +}); + +/** + * Catalog-aligned minimal: Orbit 1006 "Resource not found" required fields only. + */ +const VALID_STANDARD_ERROR: GalileoAPIStandardErrorData = { + error_code: 1006, + error_type: 'not_found_error', + error_group: 'shared', + severity: 'medium', + message: 'The requested resource could not be found.', + retriable: false, + blocking: false +}; + +/** + * Catalog-aligned full: Orbit 1006 with optional fields (dataset not found variant). + */ +const FULL_STANDARD_ERROR: GalileoAPIStandardErrorData = { + error_code: 1006, + error_type: 'not_found_error', + error_group: 'shared', + severity: 'medium', + message: 'Dataset with the given id was not found.', + user_action: 'Verify the identifier and try again.', + documentation_link: null, + retriable: false, + blocking: true, + http_status_code: 404, + source_service: 'api', + context: { dataset_id: 'ds-123' } +}; + +describe('BaseClient API error handling', () => { + test('test makeRequest throws GalileoAPIError when response has valid standard_error', async () => { + server.use( + http.get('http://localhost:8088/healthcheck', () => + HttpResponse.json( + { standard_error: VALID_STANDARD_ERROR }, + { status: 400 } + ) + ) + ); + const client = new TestClient(); + + let err: unknown; + try { + await client.testRequest(); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(GalileoAPIError); + const apiErr = err as GalileoAPIError; + expect(apiErr.message).toBe(VALID_STANDARD_ERROR.message); + expect(apiErr.errorCode).toBe(VALID_STANDARD_ERROR.error_code); + expect(apiErr.retriable).toBe(VALID_STANDARD_ERROR.retriable); + }); + + test('test makeRequest throws generic parse error when standard_error is present but invalid', async () => { + server.use( + http.get('http://localhost:8088/healthcheck', () => + HttpResponse.json({ standard_error: { message: 'x' } }, { status: 400 }) + ) + ); + const client = new TestClient(); + + await expect(client.testRequest()).rejects.toThrow( + 'The API returned an error, but the details could not be parsed.' + ); + }); + + test('test makeRequest throws generic parse error when standard_error is null', async () => { + server.use( + http.get('http://localhost:8088/healthcheck', () => + HttpResponse.json({ standard_error: null }, { status: 400 }) + ) + ); + const client = new TestClient(); + + await expect(client.testRequest()).rejects.toThrow( + 'The API returned an error, but the details could not be parsed.' + ); + }); + + test('test makeRequest throws generic parse error when standard_error is non-object', async () => { + server.use( + http.get('http://localhost:8088/healthcheck', () => + HttpResponse.json({ standard_error: 'invalid' }, { status: 400 }) + ) + ); + const client = new TestClient(); + + await expect(client.testRequest()).rejects.toThrow( + 'The API returned an error, but the details could not be parsed.' + ); + }); + + test('test makeRequest throws generic parse error when standard_error is empty object', async () => { + server.use( + http.get('http://localhost:8088/healthcheck', () => + HttpResponse.json({ standard_error: {} }, { status: 400 }) + ) + ); + const client = new TestClient(); + + await expect(client.testRequest()).rejects.toThrow( + /The API returned an error, but the details could not be parsed\./ + ); + }); + + test('test makeRequest throws generic parse error when standard_error is array', async () => { + server.use( + http.get('http://localhost:8088/healthcheck', () => + HttpResponse.json({ standard_error: [] }, { status: 400 }) + ) + ); + const client = new TestClient(); + + await expect(client.testRequest()).rejects.toThrow( + /The API returned an error, but the details could not be parsed\./ + ); + }); + + test('test makeRequest throws generic parse error when standard_error has invalid optional type', async () => { + server.use( + http.get('http://localhost:8088/healthcheck', () => + HttpResponse.json( + { + standard_error: { + ...VALID_STANDARD_ERROR, + documentation_link: 1 + } + }, + { status: 400 } + ) + ) + ); + const client = new TestClient(); + + await expect(client.testRequest()).rejects.toThrow( + /The API returned an error, but the details could not be parsed\./ + ); + }); + + test('test makeRequest throws GENERIC_ERROR_MESSAGE when error body is null', async () => { + server.use( + http.get('http://localhost:8088/healthcheck', () => + HttpResponse.json(null, { status: 500 }) + ) + ); + const client = new TestClient(); + + await expect(client.testRequest()).rejects.toThrow(GENERIC_ERROR_MESSAGE); + }); + + test('test makeRequest throws GENERIC_ERROR_MESSAGE when error body is string', async () => { + server.use( + http.get('http://localhost:8088/healthcheck', () => + HttpResponse.json('error', { status: 500 }) + ) + ); + const client = new TestClient(); + + await expect(client.testRequest()).rejects.toThrow(GENERIC_ERROR_MESSAGE); + }); + + test('test makeRequest throws GalileoAPIError with all mapped properties when response has valid standard_error', async () => { + server.use( + http.get('http://localhost:8088/healthcheck', () => + HttpResponse.json( + { standard_error: FULL_STANDARD_ERROR }, + { status: 400 } + ) + ) + ); + const client = new TestClient(); + + let err: unknown; + try { + await client.testRequest(); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(GalileoAPIError); + const apiErr = err as GalileoAPIError; + expect(apiErr.message).toBe(FULL_STANDARD_ERROR.message); + expect(apiErr.errorCode).toBe(FULL_STANDARD_ERROR.error_code); + expect(apiErr.errorType).toBe(FULL_STANDARD_ERROR.error_type); + expect(apiErr.errorGroup).toBe(FULL_STANDARD_ERROR.error_group); + expect(apiErr.severity).toBe(FULL_STANDARD_ERROR.severity); + expect(apiErr.userAction).toBe(FULL_STANDARD_ERROR.user_action); + expect(apiErr.documentationLink).toBe( + FULL_STANDARD_ERROR.documentation_link + ); + expect(apiErr.retriable).toBe(FULL_STANDARD_ERROR.retriable); + expect(apiErr.blocking).toBe(FULL_STANDARD_ERROR.blocking); + expect(apiErr.httpStatusCode).toBe(FULL_STANDARD_ERROR.http_status_code); + expect(apiErr.sourceService).toBe(FULL_STANDARD_ERROR.source_service); + expect(apiErr.context).toEqual(FULL_STANDARD_ERROR.context); + }); + + test('test makeRequest throws with detail message when detail is string and statusCode present', async () => { + server.use( + http.get('http://localhost:8088/healthcheck', () => + HttpResponse.json({ detail: 'Validation failed' }, { status: 422 }) + ) + ); + const client = new TestClient(); + + await expect(client.testRequest()).rejects.toThrow( + /non-ok status code 422 with output: Validation failed/ + ); + }); + + test('test makeRequest throws with detail message when detail is validation array', async () => { + server.use( + http.get('http://localhost:8088/healthcheck', () => + HttpResponse.json( + { + detail: [ + { + loc: ['body', 'x'], + msg: 'field required', + type: 'value_error' + } + ] + }, + { status: 422 } + ) + ) + ); + const client = new TestClient(); + + await expect(client.testRequest()).rejects.toThrow( + /non-ok status code 422 with output: field required/ + ); + }); + + test('test makeRequest throws GENERIC_ERROR_MESSAGE when error body has neither standard_error nor detail', async () => { + server.use( + http.get('http://localhost:8088/healthcheck', () => + HttpResponse.json({}, { status: 500 }) + ) + ); + const client = new TestClient(); + + await expect(client.testRequest()).rejects.toThrow(GENERIC_ERROR_MESSAGE); + }); + + test('test makeRequest throws with GENERIC_ERROR_MESSAGE when detail is array but first element has no msg', async () => { + server.use( + http.get('http://localhost:8088/healthcheck', () => + HttpResponse.json( + { + detail: [{ loc: ['body', 'x'], type: 'value_error' }] + }, + { status: 422 } + ) + ) + ); + const client = new TestClient(); + + await expect(client.testRequest()).rejects.toThrow( + /non-ok status code 422 with output: This error has been automatically tracked/ + ); + }); +}); + +describe('BaseClient path parameter substitution', () => { + test('test makeRequest substitutes project_id in route and hits correct URL', async () => { + const client = new TestClient(); + getProjectHandler.mockClear(); + + const result = await client.makeRequest<{ id: string }>( + RequestMethod.GET, + Routes.project, + null, + { project_id: 'p-1' } + ); + + expect(getProjectHandler).toHaveBeenCalled(); + expect(result).toEqual({ id: 'p-1' }); + }); +}); + +describe('BaseClient makeStreamingRequest', () => { + test('test makeStreamingRequest returns Readable and can be consumed', async () => { + const client = new TestClient(); + + const stream = await client.makeStreamingRequest( + RequestMethod.GET, + Routes.healthCheck + ); + + expect(stream).toBeDefined(); + expect(stream instanceof Readable).toBe(true); + const data = await new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on('data', (chunk: Buffer) => + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + ); + stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + stream.on('error', reject); + }); + expect(data.length).toBeGreaterThan(0); + expect(JSON.parse(data)).toEqual({ status: 'ok' }); + }); + + test('test makeStreamingRequest on 4xx with JSON stream body throws GalileoAPIError', async () => { + const body = JSON.stringify({ + standard_error: VALID_STANDARD_ERROR + }); + const streamBody = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(body)); + controller.close(); + } + }); + server.use( + http.get( + 'http://localhost:8088/healthcheck', + () => + new HttpResponse(streamBody, { + status: 400, + headers: { 'Content-Type': 'application/json' } + }) + ) + ); + const client = new TestClient(); + + let err: unknown; + try { + await client.makeStreamingRequest(RequestMethod.GET, Routes.healthCheck); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(GalileoAPIError); + expect((err as GalileoAPIError).message).toBe(VALID_STANDARD_ERROR.message); + }); + + test('test makeStreamingRequest on 4xx with non-JSON stream body throws with detail', async () => { + const streamBody = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('not valid json')); + controller.close(); + } + }); + server.use( + http.get( + 'http://localhost:8088/healthcheck', + () => new HttpResponse(streamBody, { status: 400 }) + ) + ); + const client = new TestClient(); + + await expect( + client.makeStreamingRequest(RequestMethod.GET, Routes.healthCheck) + ).rejects.toThrow(/non-ok status code 400 with output: not valid json/); + }); +}); + +describe('BaseClient validateError', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('test validateError throws Request failed when Axios error has no response', async () => { + const networkError = Object.assign(new Error('Network Error'), { + isAxiosError: true + }); + jest.spyOn(axios, 'request').mockRejectedValue(networkError); + const client = new TestClient(); + + await expect(client.testRequest()).rejects.toThrow( + 'Request failed: Network Error' + ); + }); + + test('test validateError rethrows non-Axios error', async () => { + jest.spyOn(axios, 'request').mockRejectedValue(new Error('custom')); + const client = new TestClient(); - server.close(); + await expect(client.testRequest()).rejects.toThrow('custom'); }); }); diff --git a/tests/types/errors.types.test.ts b/tests/types/errors.types.test.ts new file mode 100644 index 00000000..c2ef82b2 --- /dev/null +++ b/tests/types/errors.types.test.ts @@ -0,0 +1,274 @@ +import { + type GalileoAPIStandardErrorData, + GalileoAPIError, + isGalileoAPIStandardErrorData +} from '../../src/types/errors.types'; + +/** + * Catalog-aligned with Orbit 1006 "Resource not found" (error_catalog/errors.yaml). + * Represents "dataset not found" via 1006 with overridden message. + */ +const EXAMPLE_STANDARD_ERROR_FULL: GalileoAPIStandardErrorData = { + error_code: 1006, + error_type: 'not_found_error', + error_group: 'shared', + severity: 'medium', + message: 'Dataset with the given id was not found.', + user_action: 'Verify the identifier and try again.', + documentation_link: null, + retriable: false, + blocking: true, + http_status_code: 404, + source_service: 'api', + context: { dataset_id: 'ds-123' } +}; + +/** + * Catalog-aligned minimal: Orbit 1006 required fields only (no optional fields). + */ +const EXAMPLE_STANDARD_ERROR_MINIMAL: GalileoAPIStandardErrorData = { + error_code: 1006, + error_type: 'not_found_error', + error_group: 'shared', + severity: 'medium', + message: 'The requested resource could not be found.', + retriable: false, + blocking: false +}; + +describe('GalileoAPIError', () => { + test('test GalileoAPIError is instanceof Error', () => { + const err = new GalileoAPIError(EXAMPLE_STANDARD_ERROR_FULL); + expect(err).toBeInstanceOf(Error); + }); + + test('test GalileoAPIError construction with full standard_error data', () => { + const err = new GalileoAPIError(EXAMPLE_STANDARD_ERROR_FULL); + + expect(err.name).toBe('GalileoAPIError'); + expect(err.message).toBe(EXAMPLE_STANDARD_ERROR_FULL.message); + expect(err.errorCode).toBe(EXAMPLE_STANDARD_ERROR_FULL.error_code); + expect(err.errorType).toBe(EXAMPLE_STANDARD_ERROR_FULL.error_type); + expect(err.errorGroup).toBe(EXAMPLE_STANDARD_ERROR_FULL.error_group); + expect(err.severity).toBe(EXAMPLE_STANDARD_ERROR_FULL.severity); + expect(err.userAction).toBe(EXAMPLE_STANDARD_ERROR_FULL.user_action); + expect(err.documentationLink).toBe( + EXAMPLE_STANDARD_ERROR_FULL.documentation_link + ); + expect(err.retriable).toBe(EXAMPLE_STANDARD_ERROR_FULL.retriable); + expect(err.blocking).toBe(EXAMPLE_STANDARD_ERROR_FULL.blocking); + expect(err.httpStatusCode).toBe( + EXAMPLE_STANDARD_ERROR_FULL.http_status_code + ); + expect(err.sourceService).toBe(EXAMPLE_STANDARD_ERROR_FULL.source_service); + expect(err.context).toEqual(EXAMPLE_STANDARD_ERROR_FULL.context); + }); + + test('test GalileoAPIError construction with minimal required fields', () => { + const err = new GalileoAPIError(EXAMPLE_STANDARD_ERROR_MINIMAL); + + expect(err.name).toBe('GalileoAPIError'); + expect(err.message).toBe(EXAMPLE_STANDARD_ERROR_MINIMAL.message); + expect(err.errorCode).toBe(EXAMPLE_STANDARD_ERROR_MINIMAL.error_code); + expect(err.errorType).toBe(EXAMPLE_STANDARD_ERROR_MINIMAL.error_type); + expect(err.errorGroup).toBe(EXAMPLE_STANDARD_ERROR_MINIMAL.error_group); + expect(err.severity).toBe(EXAMPLE_STANDARD_ERROR_MINIMAL.severity); + expect(err.retriable).toBe(EXAMPLE_STANDARD_ERROR_MINIMAL.retriable); + expect(err.blocking).toBe(EXAMPLE_STANDARD_ERROR_MINIMAL.blocking); + + expect(err.userAction).toBeUndefined(); + expect(err.documentationLink).toBeNull(); + expect(err.httpStatusCode).toBeUndefined(); + expect(err.sourceService).toBeNull(); + expect(err.context).toBeNull(); + }); + + test('test GalileoAPIError toJSON includes all properties and stack', () => { + const err = new GalileoAPIError(EXAMPLE_STANDARD_ERROR_FULL); + const json = err.toJSON(); + + expect(json.name).toBe('GalileoAPIError'); + expect(json.message).toBe(EXAMPLE_STANDARD_ERROR_FULL.message); + expect(json.errorCode).toBe(EXAMPLE_STANDARD_ERROR_FULL.error_code); + expect(json.errorType).toBe(EXAMPLE_STANDARD_ERROR_FULL.error_type); + expect(json.errorGroup).toBe(EXAMPLE_STANDARD_ERROR_FULL.error_group); + expect(json.severity).toBe(EXAMPLE_STANDARD_ERROR_FULL.severity); + expect(json.userAction).toBe(EXAMPLE_STANDARD_ERROR_FULL.user_action); + expect(json.documentationLink).toBe( + EXAMPLE_STANDARD_ERROR_FULL.documentation_link + ); + expect(json.retriable).toBe(EXAMPLE_STANDARD_ERROR_FULL.retriable); + expect(json.blocking).toBe(EXAMPLE_STANDARD_ERROR_FULL.blocking); + expect(json.httpStatusCode).toBe( + EXAMPLE_STANDARD_ERROR_FULL.http_status_code + ); + expect(json.sourceService).toBe(EXAMPLE_STANDARD_ERROR_FULL.source_service); + expect(json.context).toEqual(EXAMPLE_STANDARD_ERROR_FULL.context); + expect(json.stack).toBe(err.stack); + }); + + test('test GalileoAPIError toJSON with optional fields omitted', () => { + const err = new GalileoAPIError(EXAMPLE_STANDARD_ERROR_MINIMAL); + const json = err.toJSON(); + + expect(json.name).toBe('GalileoAPIError'); + expect(json.message).toBe(EXAMPLE_STANDARD_ERROR_MINIMAL.message); + expect(json.errorCode).toBe(EXAMPLE_STANDARD_ERROR_MINIMAL.error_code); + expect(json.userAction).toBeUndefined(); + expect(json.documentationLink).toBeNull(); + expect(json.httpStatusCode).toBeUndefined(); + expect(json.sourceService).toBeNull(); + expect(json.context).toBeNull(); + expect(json.stack).toBe(err.stack); + }); +}); + +describe('isGalileoAPIStandardErrorData', () => { + test('test isGalileoAPIStandardErrorData returns true for valid full object', () => { + expect(isGalileoAPIStandardErrorData(EXAMPLE_STANDARD_ERROR_FULL)).toBe( + true + ); + }); + + test('test isGalileoAPIStandardErrorData returns true for valid minimal object', () => { + expect(isGalileoAPIStandardErrorData(EXAMPLE_STANDARD_ERROR_MINIMAL)).toBe( + true + ); + }); + + test('test isGalileoAPIStandardErrorData returns false for null and undefined', () => { + expect(isGalileoAPIStandardErrorData(null)).toBe(false); + expect(isGalileoAPIStandardErrorData(undefined)).toBe(false); + }); + + test('test isGalileoAPIStandardErrorData returns false for non-objects', () => { + expect(isGalileoAPIStandardErrorData('string')).toBe(false); + expect(isGalileoAPIStandardErrorData(123)).toBe(false); + expect(isGalileoAPIStandardErrorData(true)).toBe(false); + }); + + test('test isGalileoAPIStandardErrorData returns false when required field error_code is missing', () => { + const { error_code: _k, ...rest } = EXAMPLE_STANDARD_ERROR_FULL; + void _k; + expect(isGalileoAPIStandardErrorData(rest)).toBe(false); + }); + + test('test isGalileoAPIStandardErrorData returns false when required field error_code has wrong type', () => { + expect( + isGalileoAPIStandardErrorData({ + ...EXAMPLE_STANDARD_ERROR_MINIMAL, + error_code: '1006' + }) + ).toBe(false); + }); + + test('test isGalileoAPIStandardErrorData returns false when required field retriable has wrong type', () => { + expect( + isGalileoAPIStandardErrorData({ + ...EXAMPLE_STANDARD_ERROR_MINIMAL, + retriable: 'true' + }) + ).toBe(false); + }); + + test('test isGalileoAPIStandardErrorData returns false when user_action is non-string', () => { + expect( + isGalileoAPIStandardErrorData({ + ...EXAMPLE_STANDARD_ERROR_MINIMAL, + user_action: 1 + }) + ).toBe(false); + }); + + test('test isGalileoAPIStandardErrorData returns false when context is array', () => { + expect( + isGalileoAPIStandardErrorData({ + ...EXAMPLE_STANDARD_ERROR_MINIMAL, + context: [] + }) + ).toBe(false); + expect( + isGalileoAPIStandardErrorData({ + ...EXAMPLE_STANDARD_ERROR_MINIMAL, + context: [1, 2] + }) + ).toBe(false); + }); + + test('test isGalileoAPIStandardErrorData returns false for empty object', () => { + expect(isGalileoAPIStandardErrorData({})).toBe(false); + }); + + test('test isGalileoAPIStandardErrorData returns false when error_type is number', () => { + expect( + isGalileoAPIStandardErrorData({ + ...EXAMPLE_STANDARD_ERROR_MINIMAL, + error_type: 1 + }) + ).toBe(false); + }); + + test('test isGalileoAPIStandardErrorData returns false when message is number', () => { + expect( + isGalileoAPIStandardErrorData({ + ...EXAMPLE_STANDARD_ERROR_MINIMAL, + message: 1 + }) + ).toBe(false); + }); + + test('test isGalileoAPIStandardErrorData returns false when documentation_link is number', () => { + expect( + isGalileoAPIStandardErrorData({ + ...EXAMPLE_STANDARD_ERROR_MINIMAL, + documentation_link: 1 + }) + ).toBe(false); + }); + + test('test isGalileoAPIStandardErrorData returns false when http_status_code is string', () => { + expect( + isGalileoAPIStandardErrorData({ + ...EXAMPLE_STANDARD_ERROR_MINIMAL, + http_status_code: '404' + }) + ).toBe(false); + }); + + test('test isGalileoAPIStandardErrorData returns true when object has extra unknown properties', () => { + expect( + isGalileoAPIStandardErrorData({ + ...EXAMPLE_STANDARD_ERROR_MINIMAL, + extra: 'x', + foo: 1 + }) + ).toBe(true); + }); + + test('test isGalileoAPIStandardErrorData returns true when optional fields are null or undefined', () => { + expect( + isGalileoAPIStandardErrorData({ + ...EXAMPLE_STANDARD_ERROR_MINIMAL, + user_action: undefined + }) + ).toBe(true); + expect( + isGalileoAPIStandardErrorData({ + ...EXAMPLE_STANDARD_ERROR_MINIMAL, + user_action: null + }) + ).toBe(true); + expect( + isGalileoAPIStandardErrorData({ + ...EXAMPLE_STANDARD_ERROR_MINIMAL, + documentation_link: null + }) + ).toBe(true); + expect( + isGalileoAPIStandardErrorData({ + ...EXAMPLE_STANDARD_ERROR_MINIMAL, + context: null + }) + ).toBe(true); + }); +}); diff --git a/tests/utils/galileo-logger.test.ts b/tests/utils/galileo-logger.test.ts index c8a15111..cccde765 100644 --- a/tests/utils/galileo-logger.test.ts +++ b/tests/utils/galileo-logger.test.ts @@ -2359,39 +2359,58 @@ describe('GalileoLogger', () => { expect(createdLogger.currentParent()).toBeInstanceOf(WorkflowSpan); }); - it('should throw error when traceId not found', async () => { + it('should log and return when traceId not found', async () => { const mockTraceId = 'nonexistent-trace'; const mockInstance = createMockClient(); mockInstance.getTrace = jest.fn().mockResolvedValue(null); (GalileoApiClient as unknown as jest.Mock).mockImplementation( () => mockInstance ); + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); - await expect( - GalileoLogger.create({ - mode: 'streaming', - traceId: mockTraceId - }) - ).rejects.toThrow(`Trace ${mockTraceId} not found`); + const createdLogger = await GalileoLogger.create({ + mode: 'streaming', + traceId: mockTraceId + }); + + expect(createdLogger).toBeInstanceOf(GalileoLogger); + expect(mockInstance.getTrace).toHaveBeenCalledWith(mockTraceId); + expect(createdLogger['traceId']).toBeUndefined(); + expect(createdLogger.traces.length).toBe(0); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: `Trace ${mockTraceId} not found` }) + ); + consoleErrorSpy.mockRestore(); }); - it('should throw error when spanId not found', async () => { + it('should log and return when spanId not found', async () => { const mockSpanId = 'nonexistent-span'; const mockInstance = createMockClient(); mockInstance.getSpan = jest.fn().mockResolvedValue(null); (GalileoApiClient as unknown as jest.Mock).mockImplementation( () => mockInstance ); + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); - await expect( - GalileoLogger.create({ - mode: 'streaming', - spanId: mockSpanId - }) - ).rejects.toThrow('Span undefined not found'); + const createdLogger = await GalileoLogger.create({ + mode: 'streaming', + spanId: mockSpanId + }); + + expect(createdLogger).toBeInstanceOf(GalileoLogger); + expect(mockInstance.getSpan).toHaveBeenCalledWith(mockSpanId); + expect(createdLogger['spanId']).toBeUndefined(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Span undefined not found' }) + ); + consoleErrorSpy.mockRestore(); }); - it('should throw error when span does not belong to trace', async () => { + it('should log and return when span does not belong to trace', async () => { const mockTraceId = 'trace-123'; const mockSpanId = 'span-456'; const mockTrace = { @@ -2433,19 +2452,31 @@ describe('GalileoLogger', () => { (GalileoApiClient as unknown as jest.Mock).mockImplementation( () => mockInstance ); + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const createdLogger = await GalileoLogger.create({ + mode: 'streaming', + traceId: mockTraceId, + spanId: mockSpanId + }); - await expect( - GalileoLogger.create({ - mode: 'streaming', - traceId: mockTraceId, - spanId: mockSpanId + expect(createdLogger).toBeInstanceOf(GalileoLogger); + expect(mockInstance.getTrace).toHaveBeenCalledWith(mockTraceId); + expect(mockInstance.getSpan).toHaveBeenCalledWith(mockSpanId); + expect(createdLogger['traceId']).toBe(mockTraceId); + expect(createdLogger['spanId']).toBeUndefined(); + expect(createdLogger.traces.length).toBe(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: `Span undefined does not belong to trace ${mockTraceId}` }) - ).rejects.toThrow( - `Span undefined does not belong to trace ${mockTraceId}` ); + consoleErrorSpy.mockRestore(); }); - it('should throw error when span type is not workflow or agent', async () => { + it('should log and return when span type is not workflow or agent', async () => { const mockSpanId = 'span-456'; const mockTraceId = 'trace-123'; const mockTrace = { @@ -2487,15 +2518,26 @@ describe('GalileoLogger', () => { (GalileoApiClient as unknown as jest.Mock).mockImplementation( () => mockInstance ); + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); - await expect( - GalileoLogger.create({ - mode: 'streaming', - spanId: mockSpanId + const createdLogger = await GalileoLogger.create({ + mode: 'streaming', + spanId: mockSpanId + }); + + expect(createdLogger).toBeInstanceOf(GalileoLogger); + expect(mockInstance.getSpan).toHaveBeenCalledWith(mockSpanId); + expect(createdLogger['spanId']).toBeUndefined(); + expect(createdLogger.traces.length).toBe(0); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: + "Only 'workflow' and 'agent' span types can be initialized, got llm" }) - ).rejects.toThrow( - "Only 'workflow' and 'agent' span types can be initialized, got llm" ); + consoleErrorSpy.mockRestore(); }); }); @@ -2540,13 +2582,18 @@ describe('GalileoLogger', () => { .fn() .mockRejectedValue(new Error('API error')); mockClient.init = jest.fn().mockResolvedValue(undefined); + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); - await expect(logger['initTrace'](mockTraceId)).rejects.toThrow( - 'API error' - ); + await logger['initTrace'](mockTraceId); expect(logger['traceId']).toBe(originalTraceId); expect(logger.traces.length).toBe(0); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: 'API error' }) + ); + consoleErrorSpy.mockRestore(); }); it('should not add to parent stack when addToParentStack is false', async () => { @@ -2669,12 +2716,17 @@ describe('GalileoLogger', () => { mockClient.getSpan = jest .fn() .mockRejectedValue(new Error('API error')); + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); - await expect(logger['initSpan'](mockSpanId)).rejects.toThrow( - 'API error' - ); + await logger['initSpan'](mockSpanId); expect(logger['spanId']).toBe(originalSpanId); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: 'API error' }) + ); + consoleErrorSpy.mockRestore(); }); });