@ai2070/l0
Version:
L0: The Missing Reliability Substrate for AI
359 lines (358 loc) • 11.4 kB
JavaScript
import { ErrorCategory, RETRY_DEFAULTS } from "../types/retry";
import { calculateBackoff, sleep } from "../utils/timers";
import {
isNetworkError,
analyzeNetworkError,
isTimeoutError,
suggestRetryDelay,
NetworkErrorType
} from "../utils/errors";
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();
}
/**
* Create initial retry state
*/
createInitialState() {
return {
attempt: 0,
networkRetryCount: 0,
transientRetries: 0,
errorHistory: [],
totalDelay: 0,
limitReached: false
};
}
/**
* Categorize an error for retry decision making
*/
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
};
}
/**
* Classify error type using enhanced network error detection
*/
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 !== void 0 && statusCode >= 500 && statusCode < 600;
const isAuthError = statusCode === 401 || statusCode === 403 || message.includes("unauthorized") || message.includes("forbidden");
const isClientError = statusCode !== void 0 && statusCode >= 400 && statusCode < 500 && statusCode !== 429;
return {
isNetwork,
isRateLimit,
isServerError,
isTimeout,
isAuthError,
isClientError,
statusCode
};
}
/**
* Determine error category from classification
*/
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;
}
/**
* Infer retry reason from error classification and detailed network analysis
*/
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";
}
/**
* Decide whether to retry and calculate delay
* Enhanced with network error analysis
*/
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 !== void 0 && 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 ?? 1e4),
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
};
}
/**
* Record a retry attempt
*/
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 !== void 0 && 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);
}
}
/**
* Execute a function with retry logic
*/
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 ?? 1e4),
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);
}
}
}
/**
* Get current state
*/
getState() {
return { ...this.state };
}
/**
* Reset state
*/
reset() {
this.state = this.createInitialState();
}
/**
* Check if retry limit has been reached
*/
hasReachedLimit() {
return this.state.limitReached;
}
/**
* Get total retry count (all types)
*/
getTotalRetries() {
return this.state.attempt + this.state.networkRetryCount + this.state.transientRetries;
}
/**
* Get model failure retry count
*/
getmodelRetryCount() {
return this.state.attempt;
}
/**
* Map ErrorTypeDelays to NetworkErrorType record
*/
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
};
}
}
function createRetryManager(config) {
return new RetryManager(config);
}
function isRetryableError(error) {
const manager = new RetryManager();
const categorized = manager.categorizeError(error);
return categorized.retryable;
}
function getErrorCategory(error) {
const manager = new RetryManager();
const categorized = manager.categorizeError(error);
return categorized.category;
}
export {
RetryManager,
createRetryManager,
getErrorCategory,
isRetryableError
};
//# sourceMappingURL=retry.js.map