UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

469 lines (468 loc) • 18.2 kB
/** * Timeout utilities for NeuroLink * * Provides flexible timeout parsing and error handling for AI operations. * Supports multiple time formats: milliseconds, seconds, minutes, hours. */ import { TimeoutError as BaseTimeoutError } from "./async/withTimeout.js"; /** * Custom error class for timeout operations. * * Extends the base {@link BaseTimeoutError} (from `./async/withTimeout.js`) * rather than `Error` directly, merging what were two independent internal * `TimeoutError` classes onto one prototype chain — an `instanceof` check * against either module's `TimeoutError` now recognizes errors thrown by * this class. Constructor signature, `.name`, and the extra * `timeout`/`provider`/`operation` fields are unchanged so all 15 existing * `instanceof TimeoutError` call sites keep working exactly as before; * `super(message, timeout)` additionally populates the base class's * `timeoutMs` field (equal to `timeout`) as a byproduct. */ export class TimeoutError extends BaseTimeoutError { timeout; provider; operation; constructor(message, timeout, provider, operation) { super(message, timeout); this.timeout = timeout; this.provider = provider; this.operation = operation; this.name = "TimeoutError"; // Maintains proper stack trace for where error was thrown if (typeof Error.captureStackTrace === "function") { Error.captureStackTrace(this, TimeoutError); } } } /** * Parse timeout value from various formats * @param timeout - Can be number (ms), string with unit, or undefined * @returns Parsed timeout in milliseconds or undefined * @throws Error if format is invalid * * Examples: * - parseTimeout(5000) => 5000 * - parseTimeout('30s') => 30000 * - parseTimeout('2m') => 120000 * - parseTimeout('1.5h') => 5400000 * - parseTimeout(undefined) => undefined */ export function parseTimeout(timeout) { if (timeout === undefined) { return undefined; } if (typeof timeout === "number") { if (timeout <= 0) { throw new Error(`Timeout must be positive, got: ${timeout}`); } return timeout; // Assume milliseconds } if (typeof timeout === "string") { // Match number (including decimals) followed by optional unit const match = timeout.match(/^(\d+(?:\.\d+)?)(ms|s|m|h)?$/); if (!match) { throw new Error(`Invalid timeout format: ${timeout}. Use formats like '30s', '2m', '500ms', or '1.5h'`); } const value = parseFloat(match[1]); if (value <= 0) { throw new Error(`Timeout must be positive, got: ${value}`); } const unit = match[2] || "ms"; switch (unit) { case "ms": return value; case "s": return value * 1000; case "m": return value * 60 * 1000; case "h": return value * 60 * 60 * 1000; default: return value; // Should never reach here due to regex } } throw new Error(`Invalid timeout type: ${typeof timeout}`); } /** * Default timeout configurations for different providers and operations */ export const DEFAULT_TIMEOUTS = { global: "30s", // Default for all providers streaming: "2m", // Longer timeout for streaming operations providers: { openai: "30s", // OpenAI typically responds quickly bedrock: "45s", // AWS can be slower, especially for cold starts vertex: "60s", // Google Cloud can be slower anthropic: "60s", // Increased timeout for Anthropic API stability azure: "30s", // Azure OpenAI similar to OpenAI "google-ai": "30s", // Google AI Studio is fast huggingface: "2m", // Open source models vary significantly ollama: "5m", // Local models need more time, especially large ones mistral: "45s", // Mistral AI moderate speed litellm: "5m", // Self-hosted proxy over arbitrary backends, same variable-latency profile as ollama }, tools: { default: "10s", // Default timeout for MCP tool execution filesystem: "5s", // File operations should be quick network: "30s", // Network requests might take longer computation: "2m", // Heavy computation tools need more time }, }; /** * Get default timeout for a specific provider * @param provider - Provider name * @param operation - Operation type (generate or stream) * @returns Default timeout string */ export function getDefaultTimeout(provider, operation = "generate") { if (operation === "stream") { return DEFAULT_TIMEOUTS.streaming; } const providerKey = provider.toLowerCase().replace("_", "-"); return (DEFAULT_TIMEOUTS.providers[providerKey] || DEFAULT_TIMEOUTS.global); } /** * Create a timeout promise that rejects after specified duration * @param timeout - Timeout duration * @param provider - Provider name for error message * @param operation - Operation type for error message * @returns Promise that rejects with TimeoutError */ export function createTimeoutPromise(timeout, provider, operation) { const timeoutMs = parseTimeout(timeout); if (!timeoutMs) { return null; // No timeout } return new Promise((_, reject) => { const timer = setTimeout(() => { reject(new TimeoutError(`${provider} ${operation} operation timed out after ${timeout}`, timeoutMs, provider, operation)); }, timeoutMs); // Unref the timer so it doesn't keep the process alive (Node.js only) if (typeof timer === "object" && timer && "unref" in timer && typeof timer.unref === "function") { timer.unref(); } }); } /** * Enhanced timeout manager with proper cleanup and abort controller integration * Consolidated from timeout-manager.ts */ export class TimeoutManager { activeTimeouts = new Map(); /** * Execute operation with timeout and proper cleanup */ async executeWithTimeout(operation, config) { const startTime = Date.now(); const operationId = this.generateOperationId(config.operation); let retriesUsed = 0; const maxRetries = config.retryOnTimeout ? (config.maxRetries ?? 1) : 0; while (retriesUsed <= maxRetries) { try { const result = await this.performSingleOperation(operation, config, operationId); return { success: true, data: result, timedOut: false, executionTime: Date.now() - startTime, retriesUsed, }; } catch (error) { this.cleanup(operationId); if (error instanceof TimeoutError && retriesUsed < maxRetries) { retriesUsed++; continue; } return { success: false, error: error instanceof Error ? error : new Error(String(error)), timedOut: error instanceof TimeoutError, executionTime: Date.now() - startTime, retriesUsed, }; } } return { success: false, error: new Error("Maximum retries exceeded"), timedOut: true, executionTime: Date.now() - startTime, retriesUsed, }; } async performSingleOperation(operation, config, operationId) { const timeoutMs = this.getTimeoutMs(config); if (!timeoutMs) { return await operation(); } const controller = new AbortController(); const existingSignal = config.abortSignal; if (existingSignal) { existingSignal.addEventListener("abort", () => { controller.abort(existingSignal.reason); }); if (existingSignal.aborted) { throw new Error("Operation aborted before execution"); } } const timeoutPromise = this.createTimeoutPromise(timeoutMs, operationId); this.registerTimeout(operationId, timeoutPromise.timer, controller, () => { clearTimeout(timeoutPromise.timer); }); try { return await Promise.race([operation(), timeoutPromise.promise]); } finally { this.cleanup(operationId); } } getTimeoutMs(config) { return parseTimeout(config.timeout); } generateOperationId(operation) { return `${operation}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } createTimeoutPromise(timeoutMs, _operationId) { let timer; const promise = new Promise((_, reject) => { timer = setTimeout(() => { reject(new TimeoutError(`Operation timeout after ${timeoutMs}ms`, timeoutMs)); }, timeoutMs); }); if (!timer) { throw new Error("Failed to create timeout timer"); } return { promise, timer }; } registerTimeout(operationId, timer, controller, cleanup) { this.activeTimeouts.set(operationId, { timer, controller, cleanup }); } cleanup(operationId) { const timeoutInfo = this.activeTimeouts.get(operationId); if (timeoutInfo) { timeoutInfo.cleanup(); this.activeTimeouts.delete(operationId); } } gracefulShutdown() { for (const [operationId] of this.activeTimeouts) { this.cleanup(operationId); } } } /** * Wrapper functions consolidated from timeout-wrapper.ts */ /** * Wrap a promise with timeout * @param promise - The promise to wrap * @param timeout - Timeout duration (number in ms or string with unit) * @param provider - Provider name for error messages * @param operation - Operation type (generate or stream) * @returns The result of the promise or throws TimeoutError */ export async function withTimeout(promise, timeout, provider, operation) { const timeoutMs = parseTimeout(timeout); if (!timeoutMs) { return promise; } // The handle is captured, unref'd and cleared — the same shape // `createTimeoutPromise` above already uses. `Promise.race` settles on the // first outcome but cancels nothing, so an uncaptured timer stayed pending // for its full duration after the wrapped promise had already resolved: one // live timer per call, each holding the event loop open until it fired. The // `finally` clears it the moment the race is decided, which is what makes // this safe to wrap around something invoked once per tool call. let timeoutHandle; const timeoutPromise = new Promise((_, reject) => { const timer = setTimeout(() => { reject(new TimeoutError(`${provider} ${operation} operation timed out after ${timeoutMs}ms`, timeoutMs, provider, operation)); }, timeoutMs); timeoutHandle = timer; // Unref the timer so it doesn't keep the process alive (Node.js only) if (typeof timer === "object" && timer && "unref" in timer && typeof timer.unref === "function") { timer.unref(); } }); try { return await Promise.race([promise, timeoutPromise]); } finally { if (timeoutHandle !== undefined) { clearTimeout(timeoutHandle); } } } /** * Wrap a streaming async generator with timeout * @param generator - The async generator to wrap * @param timeout - Timeout duration for the entire stream * @param provider - Provider name for error messages * @returns Wrapped async generator that respects timeout */ export async function* withStreamingTimeout(generator, timeout, provider) { const timeoutMs = parseTimeout(timeout); if (!timeoutMs) { yield* generator; return; } let timeoutId; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { reject(new TimeoutError(`${provider} streaming operation timed out after ${timeoutMs}ms`, timeoutMs, provider, "stream")); }, timeoutMs); }); try { for await (const item of generator) { const raceResult = await Promise.race([ Promise.resolve(item), timeoutPromise, ]); yield raceResult; } } finally { if (timeoutId) { clearTimeout(timeoutId); } } } /** * Create an abort controller with timeout * @param timeout - Timeout duration * @param provider - Provider name for error messages * @param operation - Operation type * @returns AbortController and cleanup function */ export function createTimeoutController(timeout, provider, operation) { const timeoutMs = parseTimeout(timeout); if (!timeoutMs) { return null; } const controller = new AbortController(); const fire = () => { // NOTE: we cannot stamp the AI SDK's ai.streamText/ai.generateText span // from here — the setTimeout callback runs in the async context captured // at schedule time, which is BEFORE the AI SDK span exists. Instead we // rely on the AI SDK propagating the TimeoutError through its recordSpan // wrapper, which sets span.status = ERROR + message. ContextEnricher's // SpanStatusCode.ERROR branch then surfaces level=ERROR + status_message. controller.abort(new TimeoutError(`${provider} ${operation} operation timed out after ${timeout}`, timeoutMs, provider, operation)); }; let timer = setTimeout(fire, timeoutMs); const cleanup = () => { clearTimeout(timer); }; const reset = () => { if (controller.signal.aborted) { return; } clearTimeout(timer); timer = setTimeout(fire, timeoutMs); }; return { controller, cleanup, reset, timeoutMs }; } /** * Compose an external abort signal with a timeout controller's signal. * Returns a single AbortSignal that fires when either signal aborts. * If only one signal is present, returns it directly without wrapping. * * @param externalSignal - User-provided AbortSignal (e.g., from options.abortSignal) * @param timeoutSignal - Timeout controller's signal * @returns Combined AbortSignal, or undefined if neither is present */ export function composeAbortSignals(externalSignal, timeoutSignal) { if (externalSignal && timeoutSignal) { return AbortSignal.any([externalSignal, timeoutSignal]); } return externalSignal ?? timeoutSignal; } /** * Scoped variant of {@link composeAbortSignals} for per-step / per-request * composition against a LONG-LIVED external signal (e.g. the generate-call * signal inside a multi-step agent loop). `AbortSignal.any` keeps its source * registration alive until the derived signal is GC'd, so composing per step * accumulates listeners on the outer signal for the whole turn * (MaxListenersExceededWarning at 10+ steps). This helper registers plain * listeners instead and returns a `dispose()` that removes them the moment * the step settles. */ export function composeAbortSignalsScoped(externalSignal, timeoutSignal) { if (!externalSignal || !timeoutSignal) { return { signal: externalSignal ?? timeoutSignal, dispose: () => { } }; } const controller = new AbortController(); const sources = [externalSignal, timeoutSignal]; const listeners = []; const dispose = () => { for (const { source, listener } of listeners) { source.removeEventListener("abort", listener); } listeners.length = 0; }; const onAbort = (source) => { if (!controller.signal.aborted) { controller.abort(source.reason); } // Self-clean on abort: once the composed signal fired, no source // listener has any work left — detach from the OTHER source too (the // firing one auto-removed via `once`), so callers that only observe // `signal.aborted` and never reach their dispose() leave nothing behind. dispose(); }; for (const source of sources) { const listener = () => onAbort(source); source.addEventListener("abort", listener, { once: true }); listeners.push({ source, listener }); } for (const source of sources) { if (source.aborted) { onAbort(source); break; } } return { signal: controller.signal, dispose }; } /** * Merge abort signals (for combining user abort with timeout) * * Implemented via `AbortSignal.any` with a single once-listener forward, so * no per-source listeners are left behind on long-lived input signals once * the merged controller becomes unreachable (registrations are released with * the derived signal). The previous implementation attached one permanent * listener per source per call — repeated stream calls sharing one caller * signal accumulated listeners for the life of that signal. * * @param signals - Array of abort signals to merge * @returns Combined abort controller */ export function mergeAbortSignals(signals) { const controller = new AbortController(); const active = signals.filter((s) => s !== undefined); const aborted = active.find((s) => s.aborted); if (aborted) { controller.abort(aborted.reason); return controller; } if (active.length === 0) { return controller; } const merged = AbortSignal.any(active); merged.addEventListener("abort", () => { if (!controller.signal.aborted) { controller.abort(merged.reason); } }, { once: true }); // Pin the derived signal to the returned controller: sources hold only // weak refs to `any()` dependents, so without this strong ref the derived // signal (and the forward listener with it) could be GC'd before firing. controller.__nlMergedSignal = merged; return controller; }