text-processing-utils/src/errors/error-handler.ts

223 lines
5.5 KiB
TypeScript

import { withTimeout as withTimeoutRace } from '../performance/timeout-wrapper.js';
import { TextProcessingError } from './text-error.js';
export interface ErrorHandlerOptions {
maxRetries?: number;
retryDelay?: number;
exponentialBackoff?: boolean;
onError?: (error: Error) => void;
// NO FALLBACKS - fail fast per CLAUDE.md
throwOnFatal?: boolean;
}
export class ErrorHandler {
private readonly retryCount = new Map<string, number>();
constructor(private readonly options: ErrorHandlerOptions = {}) {}
async handle<T>(operation: () => Promise<T>, context?: string): Promise<T> {
const maxRetries = this.options.maxRetries || 3;
const retryKey = context || 'default';
let lastError: Error | undefined;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const result = await operation();
this.retryCount.delete(retryKey);
return result;
} catch (error) {
lastError = error as Error;
if (this.options.onError) {
this.options.onError(lastError);
}
if (this.isFatalError(lastError)) {
if (this.options.throwOnFatal !== false) {
throw lastError;
}
break;
}
if (attempt < maxRetries) {
const delay = this.calculateDelay(attempt);
await this.sleep(delay);
this.retryCount.set(retryKey, attempt + 1);
}
}
}
// NO FALLBACKS - fail fast per CLAUDE.md
throw lastError || new Error('Operation failed');
}
handleSync<T>(operation: () => T, _context?: string): T {
try {
return operation();
} catch (error) {
if (this.options.onError) {
this.options.onError(error as Error);
}
if (this.isFatalError(error as Error) && this.options.throwOnFatal !== false) {
throw error;
}
// NO FALLBACKS - fail fast per CLAUDE.md
throw error;
}
}
private isFatalError(error: Error): boolean {
// Determine if error is non-recoverable
if (error instanceof TextProcessingError) {
const fatalCodes = ['VALIDATION_ERROR', 'PARSE_ERROR', 'TEMPLATE_ERROR'];
return fatalCodes.includes(error.code || '');
}
// Check for common fatal errors
return (
error.name === 'TypeError' || error.name === 'ReferenceError' || error.name === 'SyntaxError'
);
}
private calculateDelay(attempt: number): number {
const baseDelay = this.options.retryDelay || 1000;
if (this.options.exponentialBackoff) {
return baseDelay * Math.pow(2, attempt);
}
return baseDelay;
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Generic function wrapper requires any
wrap<T extends (...args: any[]) => any>(fn: T, context?: string): T {
return ((...args: Parameters<T>) => {
if (fn.constructor.name === 'AsyncFunction') {
return this.handle(() => fn(...args), context);
}
return this.handleSync(() => fn(...args), context);
}) as T;
}
static createRetryable<T>(fn: () => Promise<T>, options?: ErrorHandlerOptions): () => Promise<T> {
const handler = new ErrorHandler(options);
return () => handler.handle(fn);
}
// REMOVED withFallback - NO FALLBACKS per CLAUDE.md
// If operation fails, it should fail fast and loud
static async withTimeout<T>(
fn: () => Promise<T>,
timeout: number,
timeoutError?: Error,
): Promise<T> {
return withTimeoutRace(
fn(),
timeout,
timeoutError?.message,
);
}
getRetryCount(context?: string): number {
return this.retryCount.get(context || 'default') || 0;
}
resetRetryCount(context?: string): void {
if (context) {
this.retryCount.delete(context);
} else {
this.retryCount.clear();
}
}
}
interface ErrorEntry {
error: Error;
context?: unknown;
timestamp: Date;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Error constructor type requires any
type ErrorConstructor<T extends Error> = new (...args: any[]) => T;
export class ErrorAggregator {
private errors: ErrorEntry[] = [];
add(error: Error, context?: unknown): void {
this.errors.push({
error,
context,
timestamp: new Date(),
});
}
getErrors(): ErrorEntry[] {
return [...this.errors];
}
getErrorsByType<T extends Error>(type: ErrorConstructor<T>): T[] {
return this.errors.filter((e) => e.error instanceof type).map((e) => e.error as T);
}
hasErrors(): boolean {
return this.errors.length > 0;
}
hasErrorType<T extends Error>(type: ErrorConstructor<T>): boolean {
return this.errors.some((e) => e.error instanceof type);
}
clear(): void {
this.errors = [];
}
throwIfErrors(): void {
if (this.errors.length === 0) {
return;
}
if (this.errors.length === 1) {
throw this.errors[0].error;
}
const aggregateError = new AggregateError(
this.errors.map((e) => e.error),
`${this.errors.length} errors occurred`,
);
throw aggregateError;
}
getSummary(): string {
const errorCounts = new Map<string, number>();
for (const { error } of this.errors) {
const name = error.name || 'Unknown';
errorCounts.set(name, (errorCounts.get(name) || 0) + 1);
}
const summary: string[] = [];
for (const [name, count] of errorCounts) {
summary.push(`${name}: ${count}`);
}
return summary.join(', ');
}
}