UNPKG

ai-functions

Version:

Core AI primitives for building intelligent applications

646 lines 22.9 kB
/** * Retry and fallback patterns for AI function calls * * Provides: * - Exponential backoff with configurable base delay and multiplier * - Jitter to prevent thundering herd (equal, full, decorrelated strategies) * - Circuit breaker for fail-fast behavior after repeated failures * - Fallback chains for model failover (sonnet -> opus -> gpt-4o) * - Error classification for intelligent retry decisions * - Partial retry for batch operations * * Per-model policy data (which models retry how, who falls back to whom, * which batch tiers each model supports) lives in `language-models`'s * `policyFor()`. The classes in this file are the *machinery* that reads * that policy. See `RetryPolicy.forModel`, `CircuitBreaker.forModel`, * `FallbackChain.forModel`. * * @packageDocumentation */ import { policyFor } from 'language-models'; // ============================================================================ // ERROR TYPES AND CLASSIFICATION // ============================================================================ /** * Error categories for retry decision making */ export var ErrorCategory; (function (ErrorCategory) { /** Network connectivity issues (retryable) */ ErrorCategory["Network"] = "network"; /** Rate limiting / quota exceeded (retryable with backoff) */ ErrorCategory["RateLimit"] = "rate_limit"; /** Invalid input / bad request (not retryable) */ ErrorCategory["InvalidInput"] = "invalid_input"; /** Authentication / authorization errors (not retryable) */ ErrorCategory["Authentication"] = "authentication"; /** Server errors (retryable) */ ErrorCategory["Server"] = "server"; /** Context length exceeded (not retryable without modification) */ ErrorCategory["ContextLength"] = "context_length"; /** Unknown error type */ ErrorCategory["Unknown"] = "unknown"; })(ErrorCategory || (ErrorCategory = {})); /** * Base class for retryable errors */ export class RetryableError extends Error { retryable = true; category; constructor(message, category = ErrorCategory.Unknown) { super(message); this.name = 'RetryableError'; this.category = category; } } /** * Base class for non-retryable errors */ export class NonRetryableError extends Error { retryable = false; category; constructor(message, category = ErrorCategory.InvalidInput) { super(message); this.name = 'NonRetryableError'; this.category = category; } } /** * Network-related errors (connection issues, timeouts) */ export class NetworkError extends RetryableError { constructor(message) { super(message, ErrorCategory.Network); this.name = 'NetworkError'; } } /** * Rate limit errors with optional retry-after */ export class RateLimitError extends RetryableError { retryAfter; constructor(message, options) { super(message, ErrorCategory.RateLimit); this.name = 'RateLimitError'; if (options?.retryAfter !== undefined) { this.retryAfter = options.retryAfter; } } /** * Create RateLimitError from HTTP response */ static fromResponse(response) { const retryAfterHeader = response.headers?.['retry-after']; let retryAfter; if (retryAfterHeader) { const seconds = parseInt(retryAfterHeader, 10); if (!isNaN(seconds)) { retryAfter = seconds * 1000; // Convert to milliseconds } } return new RateLimitError(`Rate limited (${response.status})`, retryAfter !== undefined ? { retryAfter } : undefined); } } /** * Error thrown when circuit breaker is open */ export class CircuitOpenError extends Error { retryable = false; constructor(message = 'Circuit breaker is open') { super(message); this.name = 'CircuitOpenError'; } } /** * Classify an error into a category for retry decisions */ export function classifyError(error) { if (!(error instanceof Error)) { return ErrorCategory.Unknown; } const message = error.message.toLowerCase(); const status = error.status; // Network errors if (message.includes('econnrefused') || message.includes('etimedout') || message.includes('enotfound') || message.includes('socket hang up') || message.includes('network request failed') || message.includes('fetch failed')) { return ErrorCategory.Network; } // Rate limit errors if (message.includes('rate limit') || message.includes('429') || message.includes('too many requests') || message.includes('quota exceeded') || status === 429) { return ErrorCategory.RateLimit; } // Invalid input errors if (message.includes('invalid json') || message.includes('400 bad request') || message.includes('validation failed') || status === 400 || status === 422) { return ErrorCategory.InvalidInput; } // Authentication errors if (message.includes('401 unauthorized') || message.includes('403 forbidden') || message.includes('invalid api key') || status === 401 || status === 403) { return ErrorCategory.Authentication; } // Server errors if (message.includes('500') || message.includes('502') || message.includes('503') || message.includes('504') || message.includes('internal server error') || message.includes('bad gateway') || message.includes('service unavailable') || message.includes('gateway timeout') || (status && status >= 500 && status < 600)) { return ErrorCategory.Server; } // Context length errors if (message.includes('context length') || message.includes('token limit') || message.includes('maximum context')) { return ErrorCategory.ContextLength; } return ErrorCategory.Unknown; } /** * Calculate backoff delay with exponential increase and optional jitter * * @param attempt - Current attempt number (0-indexed) * @param options - Backoff configuration * @returns Delay in milliseconds */ export function calculateBackoff(attempt, options = {}) { const { baseDelay = 1000, maxDelay = 30000, multiplier = 2, jitter = 0, jitterStrategy = 'equal', previousDelay, } = options; // Calculate base exponential delay let delay = baseDelay * Math.pow(multiplier, attempt); // Apply jitter based on strategy if (jitterStrategy === 'full') { // Full jitter: random value between 0 and calculated delay delay = Math.random() * delay; } else if (jitterStrategy === 'decorrelated' && previousDelay !== undefined) { // Decorrelated jitter: random between baseDelay and previousDelay * 3 delay = baseDelay + Math.random() * (previousDelay * 3 - baseDelay); } else if (jitter > 0) { // Equal jitter: +/- jitter% of calculated delay const jitterRange = delay * jitter; delay = delay - jitterRange + Math.random() * 2 * jitterRange; } // Apply max delay cap return Math.min(delay, maxDelay); } /** * Retry policy for executing operations with exponential backoff * * @deprecated Phase C Week 3 — `RetryPolicy` has 1 real production caller * (audited 2026-05-06; see `bd show aip-ibid`): * `ai-database/src/cascade-orchestrator.ts:1235` (loose coupling — dynamic * import + graceful try/catch fallback when ai-functions not available). * AI SDK 6's `customProvider({ retryPolicy })` and `wrapLanguageModel(model, * retryMiddleware)` cover the same surface. Migration tracked in aip-ibid; * the one callsite can move on a separate commit. Will be removed in the * Phase C semver bump. */ export class RetryPolicy { options; constructor(options = {}) { this.options = { maxRetries: options.maxRetries ?? 3, baseDelay: options.baseDelay ?? 1000, maxDelay: options.maxDelay ?? 30000, multiplier: options.multiplier ?? 2, jitter: options.jitter ?? 0, jitterStrategy: options.jitterStrategy ?? 'equal', respectRetryAfter: options.respectRetryAfter ?? true, ...(options.shouldRetry !== undefined && { shouldRetry: options.shouldRetry }), }; } /** * Build a RetryPolicy from a model's `ModelPolicy` (loaded via * `language-models`). Per-call `overrides` win over policy data. * * @example * ```ts * const policy = RetryPolicy.forModel('sonnet') * // Uses retry settings derived for anthropic/claude-sonnet-4.5 * ``` */ static forModel(alias, overrides = {}) { const policy = policyFor(alias); return RetryPolicy.fromPolicy(policy, overrides); } /** * Build a RetryPolicy directly from a `ModelPolicy`. Useful when the policy * is already in hand (e.g. from a request context). */ static fromPolicy(policy, overrides = {}) { const retryable = new Set(policy.retry.retryableCategories); const shouldRetry = (error) => { // Honour error's own retryable property when present. if (error && typeof error === 'object' && 'retryable' in error) { const flag = error.retryable; if (flag === false) return false; } const category = classifyError(error); return retryable.has(category); }; return new RetryPolicy({ maxRetries: policy.retry.maxRetries, baseDelay: policy.retry.baseDelay, maxDelay: policy.retry.maxDelay, multiplier: policy.retry.multiplier, jitter: policy.retry.jitter, shouldRetry, ...overrides, }); } /** * Execute an operation with retry logic */ async execute(operation) { let lastError; let previousDelay = this.options.baseDelay; for (let attempt = 0; attempt <= this.options.maxRetries; attempt++) { try { return await operation({ attempt, maxRetries: this.options.maxRetries }); } catch (error) { lastError = error; // Check if error is retryable if (!this.isRetryable(error)) { throw error; } // Don't wait after the last attempt if (attempt === this.options.maxRetries) { break; } // Calculate delay let delay = calculateBackoff(attempt, { baseDelay: this.options.baseDelay, maxDelay: this.options.maxDelay, multiplier: this.options.multiplier, jitter: this.options.jitter, jitterStrategy: this.options.jitterStrategy, previousDelay, }); // Respect retry-after for rate limit errors if (this.options.respectRetryAfter && error instanceof RateLimitError && error.retryAfter) { delay = error.retryAfter; } previousDelay = delay; await this.sleep(delay); } } throw lastError; } /** * Execute a batch operation with partial retry for failed items */ async executeBatch(items, batchProcessor) { const finalResults = new Map(); let pendingItems = [...items]; const attemptCounts = new Map(); // Initialize attempt counts items.forEach((item) => attemptCounts.set(item, 0)); for (let round = 0; round <= this.options.maxRetries && pendingItems.length > 0; round++) { // Wait before retry (not on first attempt) if (round > 0) { const delay = calculateBackoff(round - 1, { baseDelay: this.options.baseDelay, maxDelay: this.options.maxDelay, multiplier: this.options.multiplier, jitter: this.options.jitter, jitterStrategy: this.options.jitterStrategy, }); await this.sleep(delay); } // Process current batch const results = await batchProcessor(pendingItems); // Separate successful and failed items const failedItems = []; for (const result of results) { attemptCounts.set(result.item, (attemptCounts.get(result.item) || 0) + 1); if (result.success) { finalResults.set(result.item, result); } else { // Check if we can retry this item const attempts = attemptCounts.get(result.item) || 0; if (attempts <= this.options.maxRetries && this.isRetryable(result.error)) { failedItems.push(result.item); } else { finalResults.set(result.item, result); } } } pendingItems = failedItems; } // Return results in original order return items.map((item) => finalResults.get(item)); } isRetryable(error) { // Check custom shouldRetry function first if (this.options.shouldRetry) { return this.options.shouldRetry(error); } // Check error's own retryable property if (error && typeof error === 'object' && 'retryable' in error) { return error.retryable === true; } // Classify error and determine retryability const category = classifyError(error); return (category === ErrorCategory.Network || category === ErrorCategory.RateLimit || category === ErrorCategory.Server || category === ErrorCategory.Unknown); } sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } } /** * Circuit breaker for fail-fast behavior * * States: * - CLOSED: Normal operation, failures tracked * - OPEN: Fail fast, reject all requests * - HALF-OPEN: Allow single test request * * @deprecated Phase C Week 3 — `CircuitBreaker` has zero real callers in * primitives.org.ai (audited 2026-05-06; only comment-only references in * `language-models/src/index.ts`; see `bd show aip-ibid`). AI SDK 6's * `wrapLanguageModel(model, circuitMiddleware)` replacement is the going- * forward primitive. Will be removed in the Phase C semver bump. */ export class CircuitBreaker { _state = 'closed'; _failureCount = 0; _successCount = 0; _lastFailure = null; _lastSuccess = null; _totalFailures = 0; _totalSuccesses = 0; _openedAt = null; options; constructor(options = {}) { this.options = { failureThreshold: options.failureThreshold ?? 5, resetTimeout: options.resetTimeout ?? 30000, successThreshold: options.successThreshold ?? 1, }; } /** * Build a CircuitBreaker for a specific model, using its `ModelPolicy`. * Per-call overrides win over policy data. */ static forModel(alias, overrides = {}) { const policy = policyFor(alias); return new CircuitBreaker({ failureThreshold: policy.circuitBreaker.failureThreshold, resetTimeout: policy.circuitBreaker.resetTimeout, successThreshold: policy.circuitBreaker.successThreshold, ...overrides, }); } /** * Current circuit state */ get state() { // Check if we should transition from open to half-open if (this._state === 'open' && this._openedAt !== null) { if (Date.now() - this._openedAt >= this.options.resetTimeout) { this._state = 'half-open'; } } return this._state; } /** * Current failure count */ get failureCount() { return this._failureCount; } /** * Execute an operation through the circuit breaker */ async execute(operation) { // Check current state const currentState = this.state; if (currentState === 'open') { throw new CircuitOpenError(); } try { const result = await operation(); this.recordSuccess(); return result; } catch (error) { this.recordFailure(); throw error; } } /** * Record a successful operation */ recordSuccess() { this._successCount++; this._totalSuccesses++; this._lastSuccess = new Date(); this._failureCount = 0; // Reset failure count on success if (this._state === 'half-open') { if (this._successCount >= this.options.successThreshold) { this._state = 'closed'; this._openedAt = null; } } } /** * Record a failed operation */ recordFailure() { this._failureCount++; this._totalFailures++; this._lastFailure = new Date(); this._successCount = 0; // Reset success count on failure if (this._state === 'closed') { if (this._failureCount >= this.options.failureThreshold) { this._state = 'open'; this._openedAt = Date.now(); } } else if (this._state === 'half-open') { // Any failure in half-open state reopens the circuit this._state = 'open'; this._openedAt = Date.now(); } } /** * Get circuit breaker metrics */ getMetrics() { return { state: this.state, failureCount: this._failureCount, successCount: this._successCount, lastFailure: this._lastFailure, lastSuccess: this._lastSuccess, totalFailures: this._totalFailures, totalSuccesses: this._totalSuccesses, }; } /** * Manually reset the circuit breaker */ reset() { this._state = 'closed'; this._failureCount = 0; this._successCount = 0; this._openedAt = null; } } /** * Fallback chain for model failover * * Tries models in order until one succeeds: * sonnet -> opus -> gpt-4o -> gemini * * @deprecated Phase C Week 3 — `FallbackChain` (LLM model failover) has * zero real callers in primitives.org.ai (audited 2026-05-06; the * `human-in-the-loop` package's `FallbackChain` is a different class for * HITL fallback resolution, not LLM failover). AI SDK 4.3+ ships native * `customProvider({ fallbackProvider })` which is the going-forward * primitive. See `bd show aip-ibid`. Will be removed in the Phase C * semver bump. */ export class FallbackChain { models; options; lastMetrics = null; constructor(models, options = {}) { if (models.length === 0) { throw new Error('FallbackChain requires at least one model'); } this.models = models; this.options = options; } /** * Build a FallbackChain from a model's `ModelPolicy`. The caller supplies * an `executor` that takes a model id and returns a promise — the chain * applies it to the primary model first, then to each fallback in order. * * @example * ```ts * const chain = FallbackChain.forModel('sonnet', (modelId, params) => * ai({ model: modelId, prompt: params!.prompt }) * ) * await chain.execute({ prompt: 'Hello' }) * ``` */ static forModel(alias, executor, options = {}) { const policy = policyFor(alias); const ids = [policy.$id, ...policy.fallbackChain]; const models = ids.map((id) => ({ name: id, execute: (params) => executor(id, params), })); return new FallbackChain(models, options); } /** * Execute the fallback chain */ async execute(params) { const startTime = Date.now(); const failedModels = []; const errors = []; for (const model of this.models) { try { const result = await model.execute(params); this.lastMetrics = { attempts: failedModels.length + 1, successfulModel: model.name, failedModels, totalDuration: Date.now() - startTime, errors, }; return result; } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); failedModels.push(model.name); errors.push({ model: model.name, error: err }); // Check if we should attempt fallback if (this.options.shouldFallback && !this.options.shouldFallback(error)) { this.lastMetrics = { attempts: failedModels.length, successfulModel: null, failedModels, totalDuration: Date.now() - startTime, errors, }; throw error; } } } this.lastMetrics = { attempts: this.models.length, successfulModel: null, failedModels, totalDuration: Date.now() - startTime, errors, }; throw new Error('All fallback models failed'); } /** * Get metrics from the last execution */ getMetrics() { if (!this.lastMetrics) { return { attempts: 0, successfulModel: null, failedModels: [], totalDuration: 0, errors: [], }; } return this.lastMetrics; } } // ============================================================================ // CONVENIENCE HELPER // ============================================================================ /** * Wrap an async function with retry logic * * @example * ```ts * const reliableFetch = withRetry(fetch, { * maxRetries: 3, * baseDelay: 1000, * jitter: 0.2, * }) * * const response = await reliableFetch('https://api.example.com') * ``` */ export function withRetry(fn, options = {}) { const policy = new RetryPolicy(options); return async (...args) => { return policy.execute(() => fn(...args)); }; } //# sourceMappingURL=retry.js.map