@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
324 lines (323 loc) • 14.6 kB
JavaScript
/**
* Provider-level retry utility for AI SDK calls (NL11)
*
* The Vercel AI SDK's `generateText()` and `streamText()` have built-in retry
* logic (`_retryWithExponentialBackoff()` with default `maxRetries: 2`) that
* retries on HTTP 429/500/503. These retries are completely invisible to OTel
* because they happen inside the AI SDK.
*
* This module provides an instrumented retry wrapper that:
* 1. Disables the AI SDK's internal retries (via `maxRetries: 0`)
* 2. Implements our own retry loop with full OTel span events
* 3. Records retry attempts, delays, status codes, and total attempt count
*
* @module utils/providerRetry
*/
import { NeuroLinkError, isAbortError } from "./errorHandling.js";
import { logger } from "./logger.js";
import { APICallError } from "./generationErrors.js";
import { parseRetryAfterMs } from "./retryAfter.js";
/** Maximum number of retry attempts after the initial call (total = 1 + MAX_PROVIDER_RETRIES). */
export const MAX_PROVIDER_RETRIES = 2;
/** Base delay in ms for exponential backoff between retries. */
export const BASE_RETRY_DELAY_MS = 1000;
/** Minimum delay in ms when a retryable response provides no retry timing. */
export const NO_HINT_FLOOR_MS = 10_000;
/**
* Maximum server-requested retry delay honored by provider retries.
*
* A hint above this cap is not clamped-and-slept: the server declared an
* unavailability window we are not willing to wait out, so the error is
* surfaced immediately instead — fallback orchestration (providerFallback /
* modelChain) can route to another provider, and callers without fallback
* get a prompt rate-limit error rather than a silent multi-minute stall.
*/
export const MAX_RETRY_AFTER_MS = 60_000;
const sleepWithTimeout = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs));
/**
* Check whether an error thrown by the AI SDK is retryable.
*
* Uses `APICallError.isInstance()` for proper type-safe detection (the class
* uses a branded symbol marker, so `instanceof` doesn't work across package
* boundaries). Falls back to duck-typing for non-APICallError cases.
*/
/**
* Duck-typed HTTP status extraction for errors of unknown provenance.
* NeuroLink's hand-rolled clients stamp `.statusCode`; official SDK errors
* (e.g. @anthropic-ai/sdk APIError) expose `.status`. Shared with
* baseProvider's handleProviderError metadata preservation.
*/
export function duckTypedStatusCode(error) {
if (!error || typeof error !== "object") {
return undefined;
}
const err = error;
if (typeof err.statusCode === "number") {
return err.statusCode;
}
if (typeof err.status === "number") {
return err.status;
}
// AWS SDK v3 puts the HTTP status on `$metadata.httpStatusCode` and nowhere
// else, so without this branch no AWS error carries a status code as far as
// anything here is concerned. That is not only a retry question: this
// function also feeds `classifyProviderError` and the retry telemetry, so a
// Bedrock or SageMaker 429 was not recognisable as a rate limit, and a 5xx
// not recognisable as transient, by any status-based path. Name-based
// classification (ThrottlingException, AccessDeniedException) still worked,
// which is why this stayed hidden.
const metadata = err.$metadata;
if (typeof metadata === "object" && metadata !== null) {
const httpStatusCode = metadata
.httpStatusCode;
if (typeof httpStatusCode === "number") {
return httpStatusCode;
}
}
return undefined;
}
/**
* Duck-typed Retry-After extraction for errors of unknown provenance.
* Reads a pre-parsed `.retryAfterMs` (stamped by metadata-preserving
* wrappers like handleProviderError), then `.headers` (Headers instance or
* record — e.g. @anthropic-ai/sdk APIError), then `.responseHeaders` (the
* hand-rolled OpenAI-compatible client's record). Shared with baseProvider.
*/
export function extractRetryAfterMsFromError(error) {
if (!error || typeof error !== "object") {
return undefined;
}
const err = error;
// Number.isFinite rejects NaN/Infinity: typeof NaN === "number", and a NaN
// hint would survive Math.max() into sleep(NaN) — an immediate retry
// instead of the no-hint floor.
if (typeof err.retryAfterMs === "number" &&
Number.isFinite(err.retryAfterMs)) {
return err.retryAfterMs;
}
for (const candidate of [err.headers, err.responseHeaders]) {
if (candidate && typeof candidate === "object") {
const parsed = parseRetryAfterMs(candidate);
if (parsed !== undefined) {
return parsed;
}
}
}
return undefined;
}
/**
* OpenAI's chat transport keeps the type inside the JSON response body,
* rather than stamping it onto the raw error.
*/
export function readOpenAIBodyErrorType(responseBody) {
if (typeof responseBody !== "string") {
return undefined;
}
try {
const parsed = JSON.parse(responseBody);
const inner = parsed?.error;
return typeof inner?.type === "string" ? inner.type : undefined;
}
catch {
return undefined;
}
}
/**
* An OpenAI-wire `insufficient_quota` error: the account is out of credit or
* has hit a spend cap.
*
* Exported and shared with the OpenAI error table so the retry predicate and
* the message a caller sees cannot disagree about what a permanent billing
* state looks like. The type is NOT a field on the error — OpenAI puts it
* inside the JSON response body, which the SDK keeps as a string — so any
* caller that needs it has to parse, and none should do so on its own.
*
* Deliberately keyed on the error TYPE, never on the word "quota". OpenAI and
* xAI send 429 + `type: "insufficient_quota"` for a permanent billing state,
* but other providers use "quota" for ordinary throttling — Google's 429 reads
* "Quota exceeded for quota metric ...", which IS retryable and must stay that
* way. Matching free text here would make Gemini throttling non-retryable and
* quietly remove a layer of resilience.
*/
export function isOpenAIQuotaExhaustedError(error) {
if (typeof error !== "object" || error === null) {
return false;
}
const err = error;
if (err.type === "insufficient_quota") {
return true;
}
return readOpenAIBodyErrorType(err.responseBody) === "insufficient_quota";
}
/**
* True when `error` is an abort, or wraps one at any depth via `cause`.
*
* `isAbortError` alone is not enough here because it inspects one value, and
* by the time a provider failure reaches the retry wrapper it has usually been
* re-thrown inside a provider-specific error whose `name` no longer says
* "AbortError". The depth bound stops a self-referential or maliciously deep
* `cause` chain from hanging the classifier; nothing legitimate nests further.
*/
function isAbortLike(error, depth = 0) {
if (depth > 8 || error === null || typeof error !== "object") {
return isAbortError(error);
}
if (isAbortError(error)) {
return true;
}
const { cause } = error;
return cause === undefined || cause === error
? false
: isAbortLike(cause, depth + 1);
}
export function isRetryableProviderError(error) {
// Ahead of everything, for the same reason as the quota branch below but a
// worse failure: the caller has already walked away. Retrying a cancelled
// turn cannot succeed — the signal stays aborted, so every further attempt
// rejects without reaching the network — and it costs the full ladder,
// 2 retries at the NO_HINT_FLOOR_MS floor, before the turn ends.
//
// Measured on SageMaker before this guard: abort at 400ms, provider failed
// at 232ms with "Request aborted", then two more attempts 10s apart each
// failing in 1ms without a request leaving the process, and the turn finally
// threw at 21,996ms — 55x the time the cancellation should have taken, with
// `name` by then reading "Error" rather than "AbortError".
//
// The cause chain matters and is not defensive padding. Providers wrap
// transport failures in their own error class on the way up: SageMaker's
// handleSageMakerError() stamps an unrecognised error `statusCode: 500`
// while keeping the original as `cause`. That fabricated 500 is what made an
// abort look retryable here — the duck-typed status branch below reads it
// and returns true, outranking the wrapper's own `retryable: false`. Reading
// through `cause` finds the abort whatever wrapped it.
if (isAbortLike(error)) {
return false;
}
// Before every other branch, including the SDK's own flag: a 429 carrying
// `insufficient_quota` is marked retryable by the AI SDK because it only
// looks at the status code. Retrying it burns the full ladder — 3 attempts
// and ~20s of backoff, since no Retry-After accompanies these — on a state
// that cannot change until someone tops up the account.
if (isOpenAIQuotaExhaustedError(error)) {
return false;
}
// Preferred path: use the AI SDK's own branded type check + isRetryable flag
if (APICallError.isInstance(error)) {
return error.isRetryable;
}
if (error instanceof NeuroLinkError) {
return error.retriable;
}
// Fallback: duck-type for status codes on errors that aren't APICallError
const statusCode = duckTypedStatusCode(error);
if (statusCode !== undefined) {
return statusCode === 429 || statusCode >= 500;
}
return false;
}
/**
* Extract the HTTP status code from an AI SDK error, if available.
*/
export function getErrorStatusCode(error) {
if (APICallError.isInstance(error)) {
return error.statusCode;
}
return duckTypedStatusCode(error);
}
function getRetryAfterMs(error) {
if (APICallError.isInstance(error) && error.responseHeaders) {
const parsedDelay = parseRetryAfterMs(error.responseHeaders);
if (parsedDelay !== undefined) {
return parsedDelay;
}
}
if (error instanceof NeuroLinkError &&
error.retryAfterMs !== undefined &&
Number.isFinite(error.retryAfterMs)) {
return error.retryAfterMs;
}
return extractRetryAfterMsFromError(error);
}
/**
* Execute a provider call with instrumented retry logic.
*
* @param operation - The async operation to execute (should already use `maxRetries: 0`)
* @param span - The OTel span to annotate with retry events and attributes
* @param label - A human-readable label for log messages (e.g. "generateText", "streamText")
* @returns The result of the operation
*/
export async function withProviderRetry(operation, span, label, sleep = sleepWithTimeout) {
for (let attempt = 0; attempt <= MAX_PROVIDER_RETRIES; attempt++) {
try {
const result = await operation();
// Record how many attempts it took on the span
span?.setAttribute("gen_ai.provider.total_attempts", attempt + 1);
if (attempt > 0) {
logger.info(`[providerRetry] ${label} succeeded after ${attempt + 1} attempts`);
}
return result;
}
catch (error) {
const retryable = isRetryableProviderError(error);
const statusCode = getErrorStatusCode(error);
const errorMessage = error instanceof Error ? error.message : String(error);
if (!retryable || attempt === MAX_PROVIDER_RETRIES) {
// Record failure details before re-throwing
span?.setAttribute("gen_ai.provider.total_attempts", attempt + 1);
if (attempt > 0) {
span?.setAttribute("gen_ai.provider.retries_exhausted", true);
}
logger.warn(`[providerRetry] ${label} failed (non-retryable or retries exhausted)`, {
attempt: attempt + 1,
retryable,
statusCode,
error: errorMessage,
});
throw error;
}
const retryAfterMs = getRetryAfterMs(error);
if (retryAfterMs !== undefined && retryAfterMs > MAX_RETRY_AFTER_MS) {
span?.setAttribute("gen_ai.provider.total_attempts", attempt + 1);
span?.addEvent("gen_ai.provider.retry_suppressed", {
"retry.after_ms": retryAfterMs,
...(statusCode !== undefined && { "retry.status_code": statusCode }),
});
// Best-effort stamp so upper retry layers (the MCP generation loop's
// isRetryable check) also surface instead of re-running a
// rate-limited provider.
try {
Object.assign(error, {
isRetryable: false,
retryAfterMs,
});
}
catch {
/* frozen error — the throw below still surfaces it */
}
logger.warn(`[providerRetry] ${label} not retrying — server requested a ` +
`${Math.round(retryAfterMs / 1000)}s wait (cap ${MAX_RETRY_AFTER_MS / 1000}s); surfacing for fallback`, { attempt: attempt + 1, retryAfterMs, statusCode });
throw error;
}
const delay = (retryAfterMs === undefined ? undefined : Math.max(0, retryAfterMs)) ??
Math.max(BASE_RETRY_DELAY_MS * Math.pow(2, attempt), NO_HINT_FLOOR_MS);
// Record retry event on the OTel span
span?.addEvent("gen_ai.provider.retry", {
"retry.attempt": attempt + 1,
"retry.delay_ms": delay,
...(statusCode !== undefined && { "retry.status_code": statusCode }),
"retry.error": errorMessage.slice(0, 256),
});
logger.warn(`[providerRetry] ${label} retrying after ${statusCode || "unknown"} error`, {
attempt: attempt + 1,
maxRetries: MAX_PROVIDER_RETRIES,
delayMs: delay,
statusCode,
error: errorMessage,
});
await sleep(delay);
}
}
// This should never be reached due to the throw inside the loop,
// but TypeScript requires it for exhaustiveness.
throw new Error(`[providerRetry] ${label} exhausted all retries`);
}