ai-functions
Version:
Core AI primitives for building intelligent applications
646 lines • 22.9 kB
JavaScript
/**
* 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