/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ /** * Typed error markers used to classify failures at the MCP tool boundary * (W-22697673). The MCP adapter (`schemas/tool-adapter.ts`) maps these * to category prefixes — `Auth:` / `Schema:` / `UserInput:` — so an agent can * distinguish an infra/auth/schema failure from a user-input mistake without * parsing free text. `MutationContextError` (walker) is also treated as * UserInput; message heuristics remain a secondary fallback for untyped throws. * Everything unmatched falls through to `Internal:`. */ /** * Retryability disposition for a `Schema:` failure (W-23148365). Surfaced to the * MCP host as a closed-set `[retry=...]` token appended to the error text so an * agent can decide whether to retry, back off, or give up: * - `"now"` — retry immediately; the failure involved no live org round-trip * (a priming-lock wait timeout), so an instant retry is likely to * succeed. * - `"backoff"` — the introspection request failed transiently (HTTP 5xx/429/420 * or a network errno) AND the connection layer already exhausted * its one built-in retry, so wait with increasing backoff before * retrying — some org-side conditions (e.g. an API rate limit) may * take longer than a second or two to clear. * - `"no"` — permanent: a 4xx, a malformed/absent `__schema`, GraphQL errors * in the introspection body, or no cached schema. Don't retry — * fix the request, re-authenticate, or (re)prime via `sf_gql_connect`. * `Auth` / `UserInput` / `Internal` errors are uniformly non-retryable and never * carry a token; only `SchemaError` / `SchemaRefreshError` carry `retry`. */ export type RetryHint = "now" | "backoff" | "no"; // HTTP statuses worth a backoff-then-retry. Mirrors the connection layer's own // retry set (introspect.ts INTROSPECTION_REQUEST_OPTIONS): 420 (Salesforce // REQUEST_LIMIT_EXCEEDED legacy), 429 (Too Many Requests), 5xx gateway/server. const TRANSIENT_STATUS = new Set([420, 429, 500, 502, 503, 504]); // Deterministic client/redirect failures: retrying the identical request won't help. const PERMANENT_STATUS = new Set([400, 401, 403, 404, 405, 409, 410, 422]); // Node network errnos that typically clear on retry (transient org round-trip // failures). These are org-reachability problems, so `backoff` correctly tells // the agent to wait for the org/network to recover. const TRANSIENT_ERRNO = new Set([ "ECONNRESET", "ETIMEDOUT", "EAI_AGAIN", "ECONNREFUSED", "EPIPE", "ESOCKETTIMEDOUT", ]); // Errnos that won't clear on a short retry: a wrong host (ENOTFOUND — deliberately // treated as permanent since a typo'd instance URL is the common case, not a DNS // hiccup), a missing path, or a permissions/read-only-fs failure — PLUS local // resource-exhaustion errnos raised by the cache write (`atomicWriteJson`), which // shares the download `try`: ENOSPC (disk full), EMFILE (fd exhaustion), and EAGAIN. // These are host-side ops problems, not org round-trips, so a `backoff` hint would // both mislead the agent ("the org was unreachable") and mask the real failure; // classify them `no` so the operator sees the raw error instead of burned retries. const PERMANENT_ERRNO = new Set([ "ENOTFOUND", "ENOENT", "EACCES", "EROFS", "ENOSPC", "EMFILE", "EAGAIN", ]); /** * Classify the underlying cause of a Schema failure into a {@link RetryHint}. * * Defensive by construction: inspects an untyped `cause` (the jsforce / * `@salesforce/core` error that bubbled up from `connection.request`, or a Node * `ErrnoException` from a cache write) without assuming a type. Reads three shapes, * in order: a numeric HTTP `statusCode`, a parsed `errorCode` / `name` (e.g. * `ERROR_HTTP_503`, `REQUEST_LIMIT_EXCEEDED`), then the network/IO `code` errno. * For a real jsforce HTTP failure the `ERROR_HTTP_` regex on `errorCode`/`name` * is the load-bearing path — jsforce-node's `HttpApiError` sets string `name`/ * `errorCode` but NOT a numeric `.statusCode`, so the first branch is a defensive * fallback for other cause shapes (and the contract tests' `statusCode`-bearing * doubles), not the production trigger. Anything unrecognized returns `"no"` — we * never INVENT retryability, and the connection layer has already spent its one * transient retry before the error reaches us, so an unknown failure that survived * that retry is treated as permanent. * * Every property read is wrapped so a `cause` with a throwing accessor cannot * escape (this runs inside `runTool`'s catch, where an escaped throw would drop * the sanitized `: ` envelope and leak a raw SDK error). Real * causes (jsforce/`@salesforce/core`/`fs` errors) carry plain-data fields, so this * is a defensive backstop, not a live path; a throw simply falls back to `"no"`. */ export function classifyCause(cause: unknown): RetryHint { if (typeof cause !== "object" || cause === null) return "no"; const c = cause as Record; try { const status = typeof c.statusCode === "number" ? c.statusCode : undefined; if (status !== undefined) { if (TRANSIENT_STATUS.has(status)) return "backoff"; if (PERMANENT_STATUS.has(status)) return "no"; } const codeStr = typeof c.errorCode === "string" ? c.errorCode : ""; const nameStr = typeof c.name === "string" ? c.name : ""; // `(?!\d)` anchors the status to EXACTLY three digits: without it the greedy // `\d{3}` would capture the first three digits of a 4-digit tail (e.g. a // hypothetical `ERROR_HTTP_5001` → `500`), misreading the class. jsforce only // emits canonical 3-digit codes today, so this is defensive hardening. const httpMatch = /ERROR_HTTP_(\d{3})(?!\d)/.exec(`${codeStr} ${nameStr}`); if (httpMatch) { const httpStatus = Number(httpMatch[1]); if (TRANSIENT_STATUS.has(httpStatus)) return "backoff"; if (PERMANENT_STATUS.has(httpStatus)) return "no"; } if (codeStr === "REQUEST_LIMIT_EXCEEDED" || nameStr === "REQUEST_LIMIT_EXCEEDED") { return "backoff"; } // Network/IO errno (`code` is the errno string for a NodeJS.ErrnoException). const errno = typeof c.code === "string" ? c.code : ""; if (TRANSIENT_ERRNO.has(errno)) return "backoff"; if (PERMANENT_ERRNO.has(errno)) return "no"; } catch { // A throwing getter on the cause → treat as unclassifiable (permanent). return "no"; } return "no"; } // HTTP statuses that mean the introspection POST failed to AUTHENTICATE/authorize // (W-23335328): the org session is missing, expired, or lacks access. The fix is // to re-authenticate, so these route to `Auth:` — not `Schema:` (re-prime) — at the // MCP boundary. A subset of PERMANENT_STATUS, so they stay non-retryable too. const AUTH_STATUS = new Set([401, 403]); // Salesforce error codes that ARE a 401 in disguise. jsforce-node collapses a 4xx // whose body parses as a Salesforce error array into an HttpApiError whose // `errorCode`/`name` is the body code (e.g. INVALID_SESSION_ID for an expired // session) and sets NO numeric statusCode — so the canonical expired-session case // never presents a 401 status here and must be matched by code. Deliberately kept // to the session-invalid code we can tie authoritatively to "re-authenticate fixes // it". A structured 403 body code such as INSUFFICIENT_ACCESS / API_DISABLED_FOR_ORG // is intentionally NOT here: those are org permission/config problems that a fresh // login does NOT resolve, so routing them to `Auth:` ("re-authenticate") would // misguide the agent — they correctly stay `Schema:`. Extend only with codes where // re-auth is the right remedy (e.g. INVALID_AUTH_HEADER, MISSING_OAUTH_TOKEN) as // they are observed on this path. const AUTH_ERROR_CODES = new Set(["INVALID_SESSION_ID"]); /** * Detect whether an introspection/download `cause` is a 401/403-class auth failure * that should surface as `Auth:` (re-authenticate) rather than `Schema:` (W-23335328). * * Mirrors {@link classifyCause}'s defensive shape-reading. A real jsforce / * `@salesforce/core` HTTP failure carries a string `errorCode`/`name` — jsforce-node's * `HttpApiError` sets `ERROR_HTTP_401`/`ERROR_HTTP_403` for a status-only failure, or * the body's code (e.g. `INVALID_SESSION_ID`) for an expired session — but NOT a numeric * `statusCode`; so the `ERROR_HTTP_` regex on `errorCode`/`name` and the * `AUTH_ERROR_CODES` check are the load-bearing paths, and the `statusCode` branch is a * defensive fallback for other cause shapes (and the contract tests' status-bearing * doubles). Every property read is wrapped so a `cause` with a throwing accessor falls * back to `false` (this runs on the priming-failure path; an escaped throw would drop the * classification). Returns false for a null/undefined/non-object cause and for any * non-auth status — we never over-broaden: a 4xx that is not 401/403 stays `Schema:`. * * Note (W-23148365 N3): callers must key the Auth reclassification off THIS cause-shape * inspection, never off retry-token absence or schema-cache survival — a forced refresh * that keeps a usable cache emits no token regardless of the cause, so "no token" is not a * reliable signal that a 401/403 occurred. */ export function isAuthError(cause: unknown): boolean { if (typeof cause !== "object" || cause === null) return false; const c = cause as Record; try { const status = typeof c.statusCode === "number" ? c.statusCode : undefined; if (status !== undefined && AUTH_STATUS.has(status)) return true; const codeStr = typeof c.errorCode === "string" ? c.errorCode : ""; const nameStr = typeof c.name === "string" ? c.name : ""; // `(?!\d)` anchors to EXACTLY three digits so a 4-digit tail can't be misread // as a 401/403 (e.g. `ERROR_HTTP_4011` would otherwise capture `401` and // wrongly route to Auth). Mirrors the same guard in classifyCause. const httpMatch = /ERROR_HTTP_(\d{3})(?!\d)/.exec(`${codeStr} ${nameStr}`); if (httpMatch && AUTH_STATUS.has(Number(httpMatch[1]))) return true; if (AUTH_ERROR_CODES.has(codeStr) || AUTH_ERROR_CODES.has(nameStr)) return true; } catch { // A throwing getter on the cause → treat as not-an-auth-error (fall through // to the existing Schema classification, which is the conservative default). return false; } return false; } /** Credential/auth resolution failure (e.g. unknown org, expired token). → `Auth:` */ export class AuthError extends Error { constructor(message: string, opts?: { cause?: unknown }) { super(message, opts?.cause !== undefined ? { cause: opts.cause } : undefined); this.name = "AuthError"; } } /** * Agent-supplied input or spec violation that the user can fix: an unknown * type/field/argument named in a request, an invalid navigation, a malformed * command. → `UserInput:`. Carrying this typed marker keeps classification off * the brittle message-shape heuristics for the navigation/validation sites that * throw it. */ export class UserInputError extends Error { constructor(message: string, opts?: { cause?: unknown }) { super(message, opts?.cause !== undefined ? { cause: opts.cause } : undefined); this.name = "UserInputError"; } } /** * Schema introspection / priming / build failure. → `Schema:` * * Carries a {@link RetryHint} (`retry`, default `"no"`) stamped at the throw site * from the underlying cause (W-23148365). The MCP adapter reads this field to * append the `[retry=...]` token; throw sites that know the disposition (a * permanent missing-`__schema`, a transient lock timeout) set it explicitly, * and the lazy-prime wrap derives it via {@link classifyCause}. */ export class SchemaError extends Error { readonly retry: RetryHint; constructor(message: string, opts?: { cause?: unknown; retry?: RetryHint }) { super(message, opts?.cause !== undefined ? { cause: opts.cause } : undefined); this.name = "SchemaError"; this.retry = opts?.retry ?? "no"; } }