BREAKING CHANGE(core): Refactor to v3: introduce modular core/domain architecture, plugin system, observability and strict TypeScript configuration; remove legacy classes

This commit is contained in:
2025-11-29 18:32:00 +00:00
parent 53673e37cb
commit 7e89b6ebf5
68 changed files with 17020 additions and 720 deletions

View File

@@ -0,0 +1,327 @@
import { ErrorCode, ErrorContext } from './types.js';
/**
* Base error class for all Elasticsearch client errors
*
* @example
* ```typescript
* throw new ElasticsearchError('Connection failed', {
* code: ErrorCode.CONNECTION_FAILED,
* retryable: true,
* context: {
* timestamp: new Date(),
* operation: 'connect',
* statusCode: 503
* }
* });
* ```
*/
export class ElasticsearchError extends Error {
/** Error code for categorization */
public readonly code: ErrorCode;
/** Whether this error is retryable */
public readonly retryable: boolean;
/** Additional context about the error */
public readonly context: ErrorContext;
constructor(
message: string,
options: {
code: ErrorCode;
retryable: boolean;
context: ErrorContext;
cause?: Error;
}
) {
super(message, { cause: options.cause });
this.name = this.constructor.name;
this.code = options.code;
this.retryable = options.retryable;
this.context = {
...options.context,
timestamp: options.context.timestamp || new Date(),
};
// Maintains proper stack trace for where error was thrown (V8 only)
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
}
}
/**
* Convert error to JSON for logging/serialization
*/
toJSON(): Record<string, unknown> {
return {
name: this.name,
message: this.message,
code: this.code,
retryable: this.retryable,
context: this.context,
stack: this.stack,
};
}
/**
* Check if error is of a specific code
*/
is(code: ErrorCode): boolean {
return this.code === code;
}
/**
* Check if error is retryable
*/
canRetry(): boolean {
return this.retryable;
}
}
/**
* Connection-related errors
*/
export class ConnectionError extends ElasticsearchError {
constructor(message: string, context: Partial<ErrorContext> = {}, cause?: Error) {
super(message, {
code: ErrorCode.CONNECTION_FAILED,
retryable: true,
context: {
...context,
timestamp: new Date(),
},
cause,
});
}
}
/**
* Timeout errors
*/
export class TimeoutError extends ElasticsearchError {
constructor(
message: string,
operation: string,
timeoutMs: number,
context: Partial<ErrorContext> = {},
cause?: Error
) {
super(message, {
code: ErrorCode.REQUEST_TIMEOUT,
retryable: true,
context: {
...context,
operation,
timeout: timeoutMs,
timestamp: new Date(),
},
cause,
});
}
}
/**
* Index not found error
*/
export class IndexNotFoundError extends ElasticsearchError {
constructor(indexName: string, context: Partial<ErrorContext> = {}, cause?: Error) {
super(`Index not found: ${indexName}`, {
code: ErrorCode.INDEX_NOT_FOUND,
retryable: false,
context: {
...context,
index: indexName,
timestamp: new Date(),
},
cause,
});
}
}
/**
* Document not found error
*/
export class DocumentNotFoundError extends ElasticsearchError {
constructor(
documentId: string,
indexName?: string,
context: Partial<ErrorContext> = {},
cause?: Error
) {
super(`Document not found: ${documentId}${indexName ? ` in index ${indexName}` : ''}`, {
code: ErrorCode.DOCUMENT_NOT_FOUND,
retryable: false,
context: {
...context,
documentId,
index: indexName,
timestamp: new Date(),
},
cause,
});
}
}
/**
* Document conflict error (version mismatch, optimistic locking)
*/
export class DocumentConflictError extends ElasticsearchError {
constructor(
documentId: string,
message: string,
context: Partial<ErrorContext> = {},
cause?: Error
) {
super(message, {
code: ErrorCode.DOCUMENT_CONFLICT,
retryable: true, // Can retry with updated version
context: {
...context,
documentId,
timestamp: new Date(),
},
cause,
});
}
}
/**
* Authentication error
*/
export class AuthenticationError extends ElasticsearchError {
constructor(message: string, context: Partial<ErrorContext> = {}, cause?: Error) {
super(message, {
code: ErrorCode.AUTHENTICATION_FAILED,
retryable: false,
context: {
...context,
timestamp: new Date(),
},
cause,
});
}
}
/**
* Authorization error (insufficient permissions)
*/
export class AuthorizationError extends ElasticsearchError {
constructor(
operation: string,
resource: string,
context: Partial<ErrorContext> = {},
cause?: Error
) {
super(`Not authorized to perform ${operation} on ${resource}`, {
code: ErrorCode.AUTHORIZATION_FAILED,
retryable: false,
context: {
...context,
operation,
resource,
timestamp: new Date(),
},
cause,
});
}
}
/**
* Configuration error
*/
export class ConfigurationError extends ElasticsearchError {
constructor(message: string, context: Partial<ErrorContext> = {}, cause?: Error) {
super(message, {
code: ErrorCode.INVALID_CONFIGURATION,
retryable: false,
context: {
...context,
timestamp: new Date(),
},
cause,
});
}
}
/**
* Query parsing error
*/
export class QueryParseError extends ElasticsearchError {
constructor(query: unknown, reason: string, context: Partial<ErrorContext> = {}, cause?: Error) {
super(`Failed to parse query: ${reason}`, {
code: ErrorCode.QUERY_PARSE_ERROR,
retryable: false,
context: {
...context,
query,
timestamp: new Date(),
},
cause,
});
}
}
/**
* Bulk operation error with partial failures
*/
export class BulkOperationError extends ElasticsearchError {
public readonly successfulCount: number;
public readonly failedCount: number;
public readonly failures: Array<{
documentId?: string;
error: string;
status: number;
}>;
constructor(
message: string,
successful: number,
failed: number,
failures: Array<{ documentId?: string; error: string; status: number }>,
context: Partial<ErrorContext> = {},
cause?: Error
) {
super(message, {
code: failed === 0 ? ErrorCode.BULK_REQUEST_FAILED : ErrorCode.PARTIAL_BULK_FAILURE,
retryable: true, // Failed items can be retried
context: {
...context,
successfulCount: successful,
failedCount: failed,
timestamp: new Date(),
},
cause,
});
this.successfulCount = successful;
this.failedCount = failed;
this.failures = failures;
}
toJSON(): Record<string, unknown> {
return {
...super.toJSON(),
successfulCount: this.successfulCount,
failedCount: this.failedCount,
failures: this.failures,
};
}
}
/**
* Cluster unavailable error
*/
export class ClusterUnavailableError extends ElasticsearchError {
constructor(message: string, context: Partial<ErrorContext> = {}, cause?: Error) {
super(message, {
code: ErrorCode.CLUSTER_UNAVAILABLE,
retryable: true,
context: {
...context,
timestamp: new Date(),
},
cause,
});
}
}

14
ts/core/errors/index.ts Normal file
View File

@@ -0,0 +1,14 @@
/**
* Core error handling for Elasticsearch client
*
* This module provides:
* - Typed error hierarchy with specific error classes
* - Retry policies with configurable strategies
* - Error context and metadata
*
* @packageDocumentation
*/
export * from './types.js';
export * from './elasticsearch-error.js';
export * from './retry-policy.js';

View File

@@ -0,0 +1,196 @@
import { RetryConfig, RetryStrategy } from './types.js';
import { ElasticsearchError } from './elasticsearch-error.js';
/**
* Calculates delay based on retry strategy
*/
export class RetryDelayCalculator {
constructor(private config: RetryConfig) {}
/**
* Calculate delay for the given attempt number
*/
calculateDelay(attempt: number): number {
let delay: number;
switch (this.config.strategy) {
case 'none':
return 0;
case 'fixed':
delay = this.config.initialDelay;
break;
case 'linear':
delay = this.config.initialDelay * attempt;
break;
case 'exponential':
const multiplier = this.config.backoffMultiplier || 2;
delay = this.config.initialDelay * Math.pow(multiplier, attempt - 1);
break;
default:
delay = this.config.initialDelay;
}
// Cap at max delay
delay = Math.min(delay, this.config.maxDelay);
// Add jitter if configured
if (this.config.jitterFactor && this.config.jitterFactor > 0) {
const jitter = delay * this.config.jitterFactor * Math.random();
delay = delay + jitter;
}
return Math.floor(delay);
}
}
/**
* Default retry configuration
*/
export const DEFAULT_RETRY_CONFIG: RetryConfig = {
maxAttempts: 3,
strategy: 'exponential',
initialDelay: 1000,
maxDelay: 30000,
backoffMultiplier: 2,
jitterFactor: 0.1,
};
/**
* Determines if an error should be retried based on its characteristics
*/
export function shouldRetryError(error: Error): boolean {
// If it's our custom error, check the retryable flag
if (error instanceof ElasticsearchError) {
return error.retryable;
}
// For native errors, check specific types
if (error.name === 'TimeoutError') return true;
if (error.message.includes('ECONNREFUSED')) return true;
if (error.message.includes('ECONNRESET')) return true;
if (error.message.includes('ETIMEDOUT')) return true;
if (error.message.includes('ENETUNREACH')) return true;
if (error.message.includes('EHOSTUNREACH')) return true;
// HTTP status codes that are retryable
if ('statusCode' in error) {
const statusCode = (error as any).statusCode;
if (statusCode === 429) return true; // Too Many Requests
if (statusCode === 503) return true; // Service Unavailable
if (statusCode === 504) return true; // Gateway Timeout
if (statusCode >= 500 && statusCode < 600) return true; // Server errors
}
return false;
}
/**
* Retry policy for executing operations with automatic retry
*
* @example
* ```typescript
* const policy = new RetryPolicy({
* maxAttempts: 5,
* strategy: 'exponential',
* initialDelay: 1000,
* maxDelay: 30000,
* });
*
* const result = await policy.execute(async () => {
* return await someElasticsearchOperation();
* });
* ```
*/
export class RetryPolicy {
private config: RetryConfig;
private delayCalculator: RetryDelayCalculator;
constructor(config: Partial<RetryConfig> = {}) {
this.config = { ...DEFAULT_RETRY_CONFIG, ...config };
this.delayCalculator = new RetryDelayCalculator(this.config);
}
/**
* Execute an operation with retry logic
*/
async execute<T>(
operation: () => Promise<T>,
context?: {
operationName?: string;
onRetry?: (attempt: number, error: Error, delayMs: number) => void;
}
): Promise<T> {
let lastError: Error | undefined;
let attempt = 0;
while (attempt < this.config.maxAttempts) {
attempt++;
try {
return await operation();
} catch (error) {
lastError = error as Error;
// Check if we should retry
const shouldRetry = this.shouldRetry(lastError, attempt);
if (!shouldRetry || attempt >= this.config.maxAttempts) {
throw lastError;
}
// Calculate delay
const delay = this.delayCalculator.calculateDelay(attempt);
// Call retry callback if provided
if (context?.onRetry) {
context.onRetry(attempt, lastError, delay);
}
// Wait before retrying
await this.sleep(delay);
}
}
// Should never reach here, but TypeScript doesn't know that
throw lastError || new Error('Retry policy exhausted');
}
/**
* Determine if an error should be retried
*/
private shouldRetry(error: Error, attempt: number): boolean {
// Check custom shouldRetry function first
if (this.config.shouldRetry) {
return this.config.shouldRetry(error, attempt);
}
// Use default retry logic
return shouldRetryError(error);
}
/**
* Sleep for the specified number of milliseconds
*/
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Get current configuration
*/
getConfig(): RetryConfig {
return { ...this.config };
}
/**
* Update configuration
*/
updateConfig(config: Partial<RetryConfig>): void {
this.config = { ...this.config, ...config };
this.delayCalculator = new RetryDelayCalculator(this.config);
}
}

119
ts/core/errors/types.ts Normal file
View File

@@ -0,0 +1,119 @@
/**
* Error codes for categorizing Elasticsearch client errors
*/
export enum ErrorCode {
// Connection errors
CONNECTION_FAILED = 'CONNECTION_FAILED',
CONNECTION_TIMEOUT = 'CONNECTION_TIMEOUT',
CONNECTION_REFUSED = 'CONNECTION_REFUSED',
// Request errors
REQUEST_TIMEOUT = 'REQUEST_TIMEOUT',
REQUEST_ABORTED = 'REQUEST_ABORTED',
INVALID_REQUEST = 'INVALID_REQUEST',
// Response errors
RESPONSE_ERROR = 'RESPONSE_ERROR',
PARSE_ERROR = 'PARSE_ERROR',
// Index errors
INDEX_NOT_FOUND = 'INDEX_NOT_FOUND',
INDEX_ALREADY_EXISTS = 'INDEX_ALREADY_EXISTS',
INVALID_INDEX_NAME = 'INVALID_INDEX_NAME',
// Document errors
DOCUMENT_NOT_FOUND = 'DOCUMENT_NOT_FOUND',
DOCUMENT_ALREADY_EXISTS = 'DOCUMENT_ALREADY_EXISTS',
DOCUMENT_CONFLICT = 'DOCUMENT_CONFLICT',
VERSION_CONFLICT = 'VERSION_CONFLICT',
// Authentication & Authorization
AUTHENTICATION_FAILED = 'AUTHENTICATION_FAILED',
AUTHORIZATION_FAILED = 'AUTHORIZATION_FAILED',
INVALID_API_KEY = 'INVALID_API_KEY',
// Cluster errors
CLUSTER_UNAVAILABLE = 'CLUSTER_UNAVAILABLE',
NODE_UNAVAILABLE = 'NODE_UNAVAILABLE',
SHARD_FAILURE = 'SHARD_FAILURE',
// Query errors
QUERY_PARSE_ERROR = 'QUERY_PARSE_ERROR',
INVALID_QUERY = 'INVALID_QUERY',
SEARCH_PHASE_EXECUTION_ERROR = 'SEARCH_PHASE_EXECUTION_ERROR',
// Bulk errors
BULK_REQUEST_FAILED = 'BULK_REQUEST_FAILED',
PARTIAL_BULK_FAILURE = 'PARTIAL_BULK_FAILURE',
// Configuration errors
INVALID_CONFIGURATION = 'INVALID_CONFIGURATION',
MISSING_REQUIRED_CONFIG = 'MISSING_REQUIRED_CONFIG',
// Generic errors
UNKNOWN_ERROR = 'UNKNOWN_ERROR',
INTERNAL_ERROR = 'INTERNAL_ERROR',
}
/**
* Additional context for errors
*/
export interface ErrorContext {
/** Timestamp when error occurred */
timestamp: Date;
/** Operation that failed */
operation?: string;
/** Index name if applicable */
index?: string;
/** Document ID if applicable */
documentId?: string;
/** HTTP status code if applicable */
statusCode?: number;
/** Elasticsearch error type */
elasticsearchType?: string;
/** Elasticsearch error reason */
elasticsearchReason?: string;
/** Original error */
originalError?: Error;
/** Additional metadata */
[key: string]: unknown;
}
/**
* Retry strategy types
*/
export type RetryStrategy = 'none' | 'fixed' | 'exponential' | 'linear';
/**
* Configuration for retry behavior
*/
export interface RetryConfig {
/** Maximum number of retry attempts */
maxAttempts: number;
/** Delay strategy */
strategy: RetryStrategy;
/** Initial delay in milliseconds */
initialDelay: number;
/** Maximum delay in milliseconds */
maxDelay: number;
/** Multiplier for exponential backoff */
backoffMultiplier?: number;
/** Jitter factor (0-1) to add randomness */
jitterFactor?: number;
/** Custom function to determine if error should be retried */
shouldRetry?: (error: Error, attempt: number) => boolean;
}