@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
183 lines (182 loc) • 8.78 kB
JavaScript
/**
* Shared provider-error classification. Every provider's
* `formatProviderError(error)` delegates here instead of hand-rolling its
* own TimeoutError-check → .includes()-chain → `new XError(...)` ladder.
*
* `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);
}
/**
* 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 (404), network/connection
* errors, and 5xx server errors. 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) => ctx.statusCode === 404 ||
/model_not_found|model not found/i.test(ctx.message),
errorClass: InvalidModelError,
message: (ctx) => ctx.modelName
? `${ctx.provider} model '${ctx.modelName}' not found.`
: `${ctx.provider} model not found.`,
},
{
// 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}`,
},
];