Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 72 additions & 86 deletions src/api-client/base-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import {
ObjectToSnake,
ObjectToCamel
} from 'ts-case-convert';
import { HTTPValidationError } from '../types/errors.types';
import {
HTTPValidationError,
GalileoAPIError,
isGalileoAPIStandardErrorData
} from '../types/errors.types';

// Type guards for snake_case and camelCase conversion
type ValidatedSnakeCase<T extends object, TTarget> =
Expand All @@ -31,17 +35,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 = '';
Expand Down Expand Up @@ -149,7 +142,6 @@ export class BaseClient {
};

const response = await axios.request<T>(config);
this.validateResponse(response);
return response;
}

Expand Down Expand Up @@ -194,20 +186,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;
this.validateError(error);
}
}

Expand Down Expand Up @@ -271,21 +254,21 @@ export class BaseClient {
params && 'group_id' in params ? (params.group_id as string) : ''
)}`;

const response = await axios.request<Readable>({
method: request_method,
url: endpointPath,
params,
headers,
data,
responseType: 'stream'
});
try {
const response = await axios.request<Readable>({
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) {
this.validateError(error);
}

return response.data;
}

public convertToSnakeCase<T extends object, TTarget>(
Expand Down Expand Up @@ -346,59 +329,13 @@ export class BaseClient {
);
}

protected processResponse<T>(
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);
}
}

Expand Down Expand Up @@ -447,4 +384,53 @@ export class BaseClient {
}
return error instanceof Error ? error.message : String(error);
}

private validateError(error: unknown): never {
if (axios.isAxiosError(error)) {
if (error.response) {
this.validateAxiosResponse(error.response);
}

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);
}
} else {
throw new Error(GENERIC_ERROR_MESSAGE);
}
}
}
134 changes: 134 additions & 0 deletions src/types/errors.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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<string, unknown>;

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<string, unknown> | 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<string, unknown> {
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
};
}
}
Loading
Loading