Source: utils/errorClassifier.js

/**
 * @module utils/errorClassifier
 * @description Shared error classification for user-facing messages.
 *
 * Converts raw Error objects from AI providers, Playwright, and network
 * failures into actionable, user-friendly messages. Used by:
 * - `routes/chat.js` — chat SSE error events
 * - `utils/runWithAbort.js` — run-level failure messages
 * - `testRunner.js` — browser launch / trace setup failures
 * - `crawler.js` — pipeline warning messages
 *
 * ### Design
 * A single `classifyError(err, context)` function handles all domains.
 * The optional `context` parameter (`"chat"` | `"run"` | `"crawl"`) lets
 * the classifier tailor fallback messages and context-window hints.
 *
 * ### Exports
 * - {@link classifyError} — Classify any error into a user-friendly string.
 * - {@link ERROR_CATEGORY} — Enum of error category IDs (for frontend styling).
 */

import { isRateLimitError } from "../aiProvider.js";

/**
 * Error category identifiers. Stored on `run.errorCategory` so the
 * frontend can render category-specific banners, icons, and action buttons.
 *
 * @enum {string}
 */
export const ERROR_CATEGORY = {
  TIMEOUT:          "timeout",
  RATE_LIMIT:       "rate_limit",
  AUTH:             "auth",
  OLLAMA_OFFLINE:   "ollama_offline",
  OLLAMA_MODEL:     "ollama_model",
  CONTEXT_LENGTH:   "context_length",
  PROVIDER_OVERLOAD: "provider_overload",
  BROWSER_LAUNCH:   "browser_launch",
  NAVIGATION:       "navigation",
  NO_PROVIDER:      "no_provider",
  UNKNOWN:          "unknown",
};

/**
 * Classify an error into a user-friendly message and category.
 *
 * @param {Error}  err                 - The raw error.
 * @param {"chat"|"run"|"crawl"} [context="run"] - Where the error occurred (tailors hints).
 * @returns {{ message: string, category: string }} User-facing message and category ID.
 */
export function classifyError(err, context = "run") {
  const msg = (err?.message || "").toLowerCase();
  const status = err?.status || err?.statusCode || 0;

  // ── Timeout ───────────────────────────────────────────────────────────
  if (err?.name === "TimeoutError" || msg.includes("timed out") || msg.includes("timeout")) {
    const hint = context === "chat"
      ? "try again or switch to a faster model in Settings."
      : "check that the target URL is accessible, or try a faster AI model.";
    return {
      message: `Request timed out — the operation took too long to respond. ${hint[0].toUpperCase()}${hint.slice(1)}`,
      category: ERROR_CATEGORY.TIMEOUT,
    };
  }

  // ── Rate limit / quota exhausted ──────────────────────────────────────
  if (isRateLimitError(err)) {
    return {
      message: "AI provider rate limit reached. Wait a few minutes or switch to a different provider in Settings.",
      category: ERROR_CATEGORY.RATE_LIMIT,
    };
  }

  // ── Authentication / invalid API key ──────────────────────────────────
  if (status === 401 || status === 403
    || msg.includes("invalid api key") || msg.includes("invalid x-api-key")
    || msg.includes("incorrect api key") || msg.includes("authentication")
    || msg.includes("unauthorized") || msg.includes("permission denied")
    || msg.includes("forbidden")) {
    return {
      message: "AI provider authentication failed — your API key may be invalid or expired. Check your key in Settings.",
      category: ERROR_CATEGORY.AUTH,
    };
  }

  // ── Ollama not running ────────────────────────────────────────────────
  if (msg.includes("econnrefused") || msg.includes("fetch failed") || msg.includes("cannot reach ollama")) {
    return {
      message: "Cannot connect to Ollama — make sure it's running (ollama serve) and the URL in Settings is correct.",
      category: ERROR_CATEGORY.OLLAMA_OFFLINE,
    };
  }

  // ── Ollama model not found ────────────────────────────────────────────
  if (msg.includes("model") && (msg.includes("not found") || msg.includes("pull"))) {
    return {
      message: "Ollama model not found. Run 'ollama pull <model>' or change the model in Settings.",
      category: ERROR_CATEGORY.OLLAMA_MODEL,
    };
  }

  // ── Context too long ──────────────────────────────────────────────────
  if (msg.includes("context length") || msg.includes("too many tokens") || msg.includes("maximum context")) {
    const hint = context === "chat"
      ? "Try a shorter message or clear the conversation."
      : "Try crawling fewer pages or reducing the test count.";
    return {
      message: `Content exceeds the AI model's context window. ${hint}`,
      category: ERROR_CATEGORY.CONTEXT_LENGTH,
    };
  }

  // ── Provider overloaded (Anthropic 529, etc.) ─────────────────────────
  if (msg.includes("overloaded") || status === 529 || status === 503) {
    return {
      message: "AI provider is temporarily overloaded. Wait a moment and try again.",
      category: ERROR_CATEGORY.PROVIDER_OVERLOAD,
    };
  }

  // ── No AI provider configured ─────────────────────────────────────────
  if (msg.includes("no ai provider configured")) {
    return {
      message: "No AI provider configured. Go to Settings to add an API key or enable Ollama.",
      category: ERROR_CATEGORY.NO_PROVIDER,
    };
  }

  // ── Browser launch failure (Playwright-specific) ──────────────────────
  if (msg.includes("browser") && (msg.includes("launch") || msg.includes("executable"))
    || msg.includes("chromium") || msg.includes("playwright")) {
    return {
      message: "Failed to start the browser. Ensure Chromium is installed and the PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH is correct.",
      category: ERROR_CATEGORY.BROWSER_LAUNCH,
    };
  }

  // ── DNS resolution failure ────────────────────────────────────────────
  // Matches both our own "DNS" marker messages and Chromium's underlying
  // `net::ERR_NAME_NOT_RESOLVED`. Checked before the generic NAVIGATION
  // branch so the user gets a specific hint (typo/hostname/VPN) instead of
  // the catch-all "check the project URL" message.
  if (msg.includes("err_name_not_resolved")
    || msg.includes("enotfound")
    || (msg.includes("dns") && (msg.includes("resolv") || msg.includes("not reachable")))) {
    return {
      message: "The target host could not be resolved (DNS failure). Check the project URL for typos, verify the hostname exists, and — if the site is internal-only — ensure your VPN is connected.",
      category: ERROR_CATEGORY.NAVIGATION,
    };
  }

  // ── Navigation failure ────────────────────────────────────────────────
  if (msg.includes("navigation")
    || msg.includes("net::err_")
    || msg.includes("page.goto")
    || msg.includes("unreachable")) {
    return {
      message: "Page navigation failed. Check that the project URL is accessible and responds correctly.",
      category: ERROR_CATEGORY.NAVIGATION,
    };
  }

  // ── Fallback ──────────────────────────────────────────────────────────
  const fallbackHint = context === "chat"
    ? "Please try again. If this persists, check your AI provider configuration in Settings."
    : "Check the run logs for details. If this persists, verify your AI provider and project URL in Settings.";
  return {
    message: `An unexpected error occurred. ${fallbackHint}`,
    category: ERROR_CATEGORY.UNKNOWN,
  };
}