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:
327
ts/core/errors/elasticsearch-error.ts
Normal file
327
ts/core/errors/elasticsearch-error.ts
Normal 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
14
ts/core/errors/index.ts
Normal 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';
|
||||
196
ts/core/errors/retry-policy.ts
Normal file
196
ts/core/errors/retry-policy.ts
Normal 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
119
ts/core/errors/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user