225 lines
5.5 KiB
TypeScript
225 lines
5.5 KiB
TypeScript
|
|
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> {
|
||
|
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||
|
|
setTimeout(() => {
|
||
|
|
reject(timeoutError || new Error(`Operation timed out after ${timeout}ms`));
|
||
|
|
}, timeout);
|
||
|
|
});
|
||
|
|
|
||
|
|
return Promise.race([fn(), timeoutPromise]);
|
||
|
|
}
|
||
|
|
|
||
|
|
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(', ');
|
||
|
|
}
|
||
|
|
}
|