UNPKG

@ai2070/l0

Version:

L0: The Missing Reliability Substrate for AI

315 lines 12.3 kB
import { ErrorCategory, RETRY_DEFAULTS } from "../types/retry"; import { calculateBackoff, sleep } from "../utils/timers"; import { isNetworkError, analyzeNetworkError, isTimeoutError, suggestRetryDelay, NetworkErrorType, } from "../utils/errors"; export class RetryManager { config; state; constructor(config = {}) { this.config = { attempts: config.attempts ?? RETRY_DEFAULTS.attempts, maxRetries: config.maxRetries ?? RETRY_DEFAULTS.maxRetries, baseDelay: config.baseDelay ?? RETRY_DEFAULTS.baseDelay, maxDelay: config.maxDelay ?? RETRY_DEFAULTS.maxDelay, backoff: config.backoff ?? RETRY_DEFAULTS.backoff, retryOn: config.retryOn ?? [...RETRY_DEFAULTS.retryOn], maxErrorHistory: config.maxErrorHistory, }; this.state = this.createInitialState(); } createInitialState() { return { attempt: 0, networkRetryCount: 0, transientRetries: 0, errorHistory: [], totalDelay: 0, limitReached: false, }; } categorizeError(error, reason) { const classification = this.classifyError(error); const category = this.determineCategory(classification); const countsTowardLimit = category === ErrorCategory.MODEL; const retryable = category !== ErrorCategory.FATAL; return { error, category, reason: reason ?? this.inferReason(classification), countsTowardLimit, retryable, timestamp: Date.now(), statusCode: classification.statusCode, }; } classifyError(error) { const message = error.message?.toLowerCase() || ""; const isNetwork = isNetworkError(error); const isTimeout = isTimeoutError(error); let statusCode; const statusMatch = message.match(/status\s*(?:code)?\s*:?\s*(\d{3})/i); if (statusMatch && statusMatch[1]) { statusCode = parseInt(statusMatch[1], 10); } const isRateLimit = statusCode === 429 || message.includes("rate limit"); const isServerError = statusCode !== undefined && statusCode >= 500 && statusCode < 600; const isAuthError = statusCode === 401 || statusCode === 403 || message.includes("unauthorized") || message.includes("forbidden"); const isClientError = statusCode !== undefined && statusCode >= 400 && statusCode < 500 && statusCode !== 429; return { isNetwork, isRateLimit, isServerError, isTimeout, isAuthError, isClientError, statusCode, }; } determineCategory(classification) { if (classification.isNetwork) { return ErrorCategory.NETWORK; } if (classification.isRateLimit || classification.isServerError || classification.isTimeout) { return ErrorCategory.TRANSIENT; } if (classification.isAuthError || (classification.isClientError && !classification.isRateLimit)) { return ErrorCategory.FATAL; } return ErrorCategory.MODEL; } inferReason(classification, error) { if (classification.isNetwork) { if (error) { const analysis = analyzeNetworkError(error); switch (analysis.type) { case "connection_dropped": case "econnreset": case "econnrefused": case "sse_aborted": case "partial_chunks": case "no_bytes": return "network_error"; case "runtime_killed": case "timeout": return "timeout"; default: return "network_error"; } } return "network_error"; } if (classification.isTimeout) return "timeout"; if (classification.isRateLimit) return "rate_limit"; if (classification.isServerError) return "server_error"; return "unknown"; } shouldRetry(error, reason) { const categorized = this.categorizeError(error, reason); if (categorized.category === ErrorCategory.NETWORK && isNetworkError(error)) { const analysis = analyzeNetworkError(error); if (!analysis.retryable) { return { shouldRetry: false, delay: 0, reason: `Fatal network error: ${analysis.suggestion}`, category: ErrorCategory.FATAL, countsTowardLimit: false, }; } } if (categorized.category === ErrorCategory.FATAL) { return { shouldRetry: false, delay: 0, reason: "Fatal error - not retryable", category: categorized.category, countsTowardLimit: false, }; } if (categorized.reason && !this.config.retryOn.includes(categorized.reason)) { return { shouldRetry: false, delay: 0, reason: `Retry reason '${categorized.reason}' not in retryOn list`, category: categorized.category, countsTowardLimit: false, }; } if (this.config.maxRetries !== undefined && this.getTotalRetries() >= this.config.maxRetries) { this.state.limitReached = true; return { shouldRetry: false, delay: 0, reason: `Absolute maximum retries (${this.config.maxRetries}) reached`, category: categorized.category, countsTowardLimit: false, }; } if (categorized.countsTowardLimit && this.state.attempt >= this.config.attempts) { this.state.limitReached = true; return { shouldRetry: false, delay: 0, reason: "Maximum retry attempts reached", category: categorized.category, countsTowardLimit: true, }; } const attemptCount = categorized.countsTowardLimit ? this.state.attempt : categorized.category === ErrorCategory.NETWORK ? this.state.networkRetryCount : this.state.transientRetries; let backoff; if (categorized.category === ErrorCategory.NETWORK && this.config.errorTypeDelays && isNetworkError(error)) { const customDelayMap = this.mapErrorTypeDelays(this.config.errorTypeDelays); const customDelay = suggestRetryDelay(error, attemptCount, customDelayMap, this.config.maxDelay); backoff = { delay: customDelay, cappedAtMax: customDelay >= (this.config.maxDelay ?? 10000), rawDelay: customDelay, }; } else { backoff = calculateBackoff(this.config.backoff, attemptCount, this.config.baseDelay, this.config.maxDelay); } return { shouldRetry: true, delay: backoff.delay, reason: `Retrying after ${categorized.category} error`, category: categorized.category, countsTowardLimit: categorized.countsTowardLimit, }; } async recordRetry(categorizedError, decision) { if (decision.countsTowardLimit) { this.state.attempt++; } else if (categorizedError.category === ErrorCategory.NETWORK) { this.state.networkRetryCount++; } else if (categorizedError.category === ErrorCategory.TRANSIENT) { this.state.transientRetries++; } this.state.lastError = categorizedError; this.state.errorHistory.push(categorizedError); const maxHistory = this.config.maxErrorHistory; if (maxHistory !== undefined && this.state.errorHistory.length > maxHistory) { this.state.errorHistory = this.state.errorHistory.slice(-maxHistory); } this.state.totalDelay += decision.delay; if (decision.delay > 0) { await sleep(decision.delay); } } async execute(fn, onRetry) { while (true) { try { const result = await fn(); return result; } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); const categorized = this.categorizeError(err); const decision = this.shouldRetry(err); if (!decision.shouldRetry) { throw err; } const attemptCount = decision.countsTowardLimit ? this.state.attempt : categorized.category === ErrorCategory.NETWORK ? this.state.networkRetryCount : this.state.transientRetries; let backoff; if (categorized.category === ErrorCategory.NETWORK && this.config.errorTypeDelays && isNetworkError(err)) { const customDelayMap = this.mapErrorTypeDelays(this.config.errorTypeDelays); const customDelay = suggestRetryDelay(err, attemptCount, customDelayMap, this.config.maxDelay); backoff = { delay: customDelay, cappedAtMax: customDelay >= (this.config.maxDelay ?? 10000), rawDelay: customDelay, }; } else { backoff = calculateBackoff(this.config.backoff, attemptCount, this.config.baseDelay, this.config.maxDelay); } if (onRetry) { onRetry({ state: this.getState(), config: this.config, error: categorized, backoff, }); } await this.recordRetry(categorized, decision); } } } getState() { return { ...this.state }; } reset() { this.state = this.createInitialState(); } hasReachedLimit() { return this.state.limitReached; } getTotalRetries() { return (this.state.attempt + this.state.networkRetryCount + this.state.transientRetries); } getmodelRetryCount() { return this.state.attempt; } mapErrorTypeDelays(delays) { return { [NetworkErrorType.CONNECTION_DROPPED]: delays.connectionDropped, [NetworkErrorType.FETCH_ERROR]: delays.fetchError, [NetworkErrorType.ECONNRESET]: delays.econnreset, [NetworkErrorType.ECONNREFUSED]: delays.econnrefused, [NetworkErrorType.SSE_ABORTED]: delays.sseAborted, [NetworkErrorType.NO_BYTES]: delays.noBytes, [NetworkErrorType.PARTIAL_CHUNKS]: delays.partialChunks, [NetworkErrorType.RUNTIME_KILLED]: delays.runtimeKilled, [NetworkErrorType.BACKGROUND_THROTTLE]: delays.backgroundThrottle, [NetworkErrorType.DNS_ERROR]: delays.dnsError, [NetworkErrorType.TIMEOUT]: delays.timeout, [NetworkErrorType.UNKNOWN]: delays.unknown, }; } } export function createRetryManager(config) { return new RetryManager(config); } export function isRetryableError(error) { const manager = new RetryManager(); const categorized = manager.categorizeError(error); return categorized.retryable; } export function getErrorCategory(error) { const manager = new RetryManager(); const categorized = manager.categorizeError(error); return categorized.category; } //# sourceMappingURL=retry.js.map