@ai2070/l0
Version:
L0: The Missing Reliability Substrate for AI
315 lines • 12.3 kB
JavaScript
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