@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
232 lines (231 loc) • 12 kB
JavaScript
/**
* Shared provider-error classification. Migrated providers'
* `formatProviderError(error)` delegate here instead of hand-rolling their
* own TimeoutError-check → .includes()-chain → `new XError(...)` ladder.
* Not yet migrated (they do not call it): Google AI Studio, SageMaker, the
* media and embedding providers (Ideogram, Recraft, Stability, Replicate,
* Jina, Voyage) and the System One decision provider.
*
* `classifyProviderError` picks the Error subclass + message; it does NOT
* stamp statusCode/isRetryable/retryAfterMs onto the result — that
* passthrough already happens generically in
* `BaseProvider.handleProviderError()` (src/lib/core/baseProvider.ts) for
* every provider's returned error, migrated or not, so duplicating it here
* would risk the two copies disagreeing.
*/
import { ProviderError, AuthenticationError, RateLimitError, InvalidModelError, NetworkError, } from "../types/index.js";
import { TimeoutError } from "./timeout.js";
import { duckTypedStatusCode } from "./providerRetry.js";
import { TRANSIENT_NETWORK_CODES } from "../constants/networkErrorCodes.js";
import { redactUrlsInText } from "./logSanitize.js";
/** Bounded walk depth for `.cause` chains — matches the precedent in
* `proxy/proxyFetch.ts`'s `isTransientNetworkError`. Guards against
* pathological/cyclic `.cause` chains hanging classification. */
const MAX_CAUSE_DEPTH = 5;
/**
* Walk `error.cause` up to `MAX_CAUSE_DEPTH` links, guarded by a seen-set so
* a cyclic chain (`a.cause === a`, or a longer cycle) terminates instead of
* looping. Node's native `fetch` (undici) throws `TypeError: fetch failed`
* with the real transport error nested under `.cause` — sometimes another
* level deep (e.g. a SocketError inside a ConnectTimeoutError) — so a
* classifier that only reads the outer error's `.message`/`.code` never
* sees it.
*/
function collectCauseChain(error) {
const chain = [];
const seen = new Set();
let current = error;
while (current &&
typeof current === "object" &&
!seen.has(current) &&
chain.length < MAX_CAUSE_DEPTH) {
seen.add(current);
const record = current;
chain.push(record);
current = record.cause;
}
return chain;
}
function firstString(chain, key) {
for (const record of chain) {
if (typeof record[key] === "string") {
return record[key];
}
}
return undefined;
}
function buildErrorContext(error, provider, modelName) {
const chain = collectCauseChain(error);
const top = chain[0];
const topMessage = typeof top?.message === "string"
? top.message
: error instanceof Error
? error.message
: "Unknown error";
// Compose (never replace) the message: append the deepest cause's message
// when it differs from the top, so existing rules matching the outer text
// (e.g. "rate limit", "model not found") keep matching, while the real
// transport failure buried in .cause becomes visible to rules that need
// it (e.g. a nested "ECONNREFUSED").
// The nested message is redacted before it is composed in: an undici cause
// carries the full request URL, so a presigned token would otherwise reach
// a client-facing error message through this path. Only the nested text is
// scrubbed — the provider's own top-level message is left alone, since
// several providers deliberately name their base URL in it.
const deepest = chain[chain.length - 1];
const deepestMessage = typeof deepest?.message === "string"
? redactUrlsInText(deepest.message)
: undefined;
const message = deepestMessage && deepestMessage !== topMessage
? `${topMessage}: ${deepestMessage}`
: topMessage;
// errorCode/errorName/statusCode: prefer the outer error's own value,
// falling back to the first cause in the chain that has one.
let statusCode;
for (const record of chain) {
statusCode = duckTypedStatusCode(record);
if (statusCode !== undefined) {
break;
}
}
return {
error,
message,
statusCode,
errorName: firstString(chain, "name"),
errorCode: firstString(chain, "code"),
provider,
modelName,
};
}
/**
* Classify a raw provider error into a NeuroLink `ProviderError` subclass.
* `rules` are tried in order; the first match wins. `TimeoutError` is
* always handled first, ahead of any rule table — every provider treated
* it identically before this change, so it is not made overridable.
*/
export function classifyProviderError(error, rules, provider, modelName) {
if (error instanceof TimeoutError) {
return new NetworkError(`Request timed out: ${error.message}`, provider);
}
const ctx = buildErrorContext(error, provider, modelName);
const rule = rules.find((r) => r.match(ctx));
if (!rule) {
return new ProviderError(`${provider} error: ${ctx.message}`, provider);
}
const message = typeof rule.message === "function" ? rule.message(ctx) : rule.message;
return new rule.errorClass(message, provider);
}
// A 404 alone is a route answer (a wrong base URL gives the same reply): it only
// means "missing model" when the text names a model or deployment as absent.
// The gap is bounded rather than "no dot" because real model ids contain dots.
// "model" or "deployment" followed by a route noun ("model gateway route") is a
// modifier of that route, not the subject of the 404, in either word order, and
// that holds for the named phrases ("unknown model gateway route") as well.
const NOT_A_ROUTE_MODIFIER = "(?![\\s-]+(?:route|gateway|endpoint|url|path|proxy|server|service|api|host)s?(?![\\w-]))";
const MODEL_WORD = `\\bmodel\\b${NOT_A_ROUTE_MODIFIER}`;
const MODEL_OR_DEPLOYMENT = `\\b(?:model|deployment)\\b${NOT_A_ROUTE_MODIFIER}`;
const NAMED_MODEL_ERROR = `(?:unknown|no such|invalid|unsupported) model${NOT_A_ROUTE_MODIFIER}`;
const MODEL_404_TEXT = new RegExp(`model[_ ]?not[_ ]?found|${NAMED_MODEL_ERROR}|${MODEL_OR_DEPLOYMENT}.{0,120}\\b(?:does not exist|not found|unavailable|not (?:available|supported))\\b|\\b(?:does not exist|not found)\\b.{0,120}${MODEL_OR_DEPLOYMENT}|unable to access.{0,60}${MODEL_WORD}`, "i");
/**
* True when `status` is written in `message` as an HTTP status, as opposed to
* an unrelated number that happens to have the same digits ("max_tokens (429)
* exceeds the model limit", a request id, a token count). Provider rules that
* once tested the bare digits (`/429/`) classified such a 400 as a rate limit.
*
* Status-shaped means one of:
* - the text starts with it, the way some SDKs format an API error
* ("429 {...}", "401 Unauthorized");
* - it follows "status", "status code" or "HTTP" ("Request failed with status
* code 429", "HTTP/1.1 429");
* - it sits next to the word "error" in either order ("error 429", "429 error",
* and the nested JSON body `{"error":{"code":429`).
*
* The gap between the word and the number is bounded and holds no digit, so
* unrelated digits further along cannot bridge the match. This is the shape the
* shared 5xx rule in `DEFAULT_ERROR_RULES` already uses, so the two stay in
* step. It is a heuristic over text: a rule that can read the structured
* status should still check `ctx.statusCode` first.
*/
export function messageNamesStatus(message, status) {
if (!Number.isInteger(status) || status < 100 || status > 599) {
return false;
}
const code = String(status);
return new RegExp(`^\\s*${code}\\b|\\b(?:error|status(?:\\s*code)?)\\b\\D{0,12}\\b${code}\\b|\\bhttp(?:/\\d(?:\\.\\d)?)?[\\s:-]{0,3}${code}\\b|\\b${code}\\b\\D{0,12}\\berror\\b`, "i").test(message);
}
/**
* Generic fallback rule table covering the five categories every
* OpenAI-compatible provider already hand-rolled near-identically:
* auth (401), rate limit (429), model-not-found, network/connection
* errors, and 5xx server errors. Model-not-found is a 404 whose text names
* the model or deployment as missing, or the old "model not found" message
* text at any status; any other 404 stays a plain `ProviderError` carrying
* the status and the vendor's message. Providers with a provider-specific
* auth message (naming the exact env var) prepend one override rule and
* spread this table after it — see errorClassifier usage in any migrated
* provider's formatProviderError for the pattern.
*/
export const DEFAULT_ERROR_RULES = [
{
match: (ctx) => ctx.statusCode === 401 ||
/API_KEY_INVALID|Invalid API key|Unauthorized|invalid_api_key/i.test(ctx.message),
errorClass: AuthenticationError,
message: (ctx) => `Invalid ${ctx.provider} API key. Please check your credentials.`,
},
{
match: (ctx) => ctx.statusCode === 429 || /rate limit/i.test(ctx.message),
errorClass: RateLimitError,
message: (ctx) => `${ctx.provider} rate limit exceeded. Please try again later.`,
},
{
match: (ctx) => /model_not_found|model not found/i.test(ctx.message) ||
(ctx.statusCode === 404 && MODEL_404_TEXT.test(ctx.message)),
errorClass: InvalidModelError,
message: (ctx) => ctx.modelName
? `${ctx.provider} model '${ctx.modelName}' not found.`
: `${ctx.provider} model not found.`,
},
{
match: (ctx) => ctx.statusCode === 404,
errorClass: ProviderError,
message: (ctx) => `${ctx.provider} returned HTTP 404: ${ctx.message}`,
},
{
// Message regex covers providers/SDKs that surface a code as text
// (e.g. AWS SDK wrapping "ECONNRESET" into its own message). errorCode
// covers undici's native fetch(), which wraps transport failures as
// `TypeError: fetch failed` and puts the *structured* code
// (ECONNREFUSED, UND_ERR_SOCKET, ...) on a nested `.cause` rather than
// in any message text — buildErrorContext's cause walk surfaces it here.
match: (ctx) => /ECONNRESET|ENOTFOUND|ECONNREFUSED|ETIMEDOUT|network|connection/i.test(ctx.message) ||
(ctx.errorCode !== undefined &&
TRANSIENT_NETWORK_CODES.has(ctx.errorCode)),
errorClass: NetworkError,
message: (ctx) => `Connection error: ${ctx.message}`,
},
{
// Batch J Task 3: the old `/\b5\d\d\b/` matched ANY bare 3-digit number
// in [500,599) anywhere in the message — e.g. "max_tokens (500) exceeds
// model limit" — with no relation to an actual HTTP status. Tightened to
// require the number sit in a status-shaped context: immediately next
// to "error" (either order) or "status"/"status code" (a common HTTP
// client wrapper phrase, e.g. axios's "Request failed with status code
// 500"), with a bounded gap so unrelated digits nearby can't bridge the
// match — or a named 5xx phrase that needs no digit at all ("bad
// gateway", "service unavailable", "gateway timeout", "server error",
// which already covers "... Internal Server Error"). This changes the
// MATCHED MESSAGE TEXT only, never the classified class: when no rule
// matches, `classifyProviderError`'s fallback also returns
// `ProviderError` (see above) — the same class this rule assigns — so
// narrowing this regex can only move a message between "${provider}
// server error: ..." and "${provider} error: ...", never between error
// classes.
match: (ctx) => (ctx.statusCode !== undefined &&
ctx.statusCode >= 500 &&
ctx.statusCode <= 599) ||
/server error|bad gateway|service unavailable|gateway timeout|\berror\b\D{0,12}\b5\d\d\b|\b5\d\d\b\D{0,12}\berror\b|\bstatus(?:\s*code)?\b\D{0,12}\b5\d\d\b/i.test(ctx.message),
errorClass: ProviderError,
message: (ctx) => `${ctx.provider} server error: ${ctx.message}`,
},
];