@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
JavaScript
/**
* 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;
}