/ src / browser / errors.ts
errors.ts
  1  /**
  2   * Browser connection error helpers.
  3   *
  4   * Simplified — no more token/extension/CDP classification.
  5   * The daemon architecture has a single failure mode: daemon not reachable or extension not connected.
  6   */
  7  
  8  import { BrowserConnectError, type BrowserConnectKind } from '../errors.js';
  9  import { DEFAULT_DAEMON_PORT } from '../constants.js';
 10  
 11  /**
 12   * Unified browser error classification.
 13   *
 14   * All transient error detection lives here — daemon-client, pipeline executor,
 15   * and page retry logic all use this single system instead of maintaining
 16   * separate pattern lists.
 17   */
 18  
 19  /** Error category — determines which layer should retry. */
 20  export type BrowserErrorKind =
 21    | 'extension-transient'   // daemon/extension hiccup — daemon-client retries
 22    | 'target-navigation'     // CDP target invalidated by SPA nav — page-level settle retry
 23    | 'non-retryable';        // permanent error — no retry
 24  
 25  /** How the caller should handle the error. */
 26  export interface RetryAdvice {
 27    /** Error category — callers use this to decide whether *they* should retry. */
 28    kind: BrowserErrorKind;
 29    /** Whether the error is transient and worth retrying. */
 30    retryable: boolean;
 31    /** Suggested delay before retry (ms). */
 32    delayMs: number;
 33  }
 34  
 35  /**
 36   * Extension/daemon transient patterns — service worker restarts, attach races,
 37   * tab closure, daemon hiccups. These warrant a longer retry delay (~1500ms)
 38   * because the extension needs time to recover.
 39   */
 40  const EXTENSION_TRANSIENT_PATTERNS = [
 41    'Extension disconnected',
 42    'Extension not connected',
 43    'attach failed',
 44    'no longer exists',
 45    'CDP connection',
 46    'Daemon command failed',
 47    'No window with id',
 48  ] as const;
 49  
 50  /**
 51   * CDP target navigation patterns — SPA client-side redirects can invalidate the
 52   * CDP target after chrome.tabs reports 'complete'. These warrant a shorter retry
 53   * delay (~200ms) because the new document is usually available quickly.
 54   */
 55  const TARGET_NAVIGATION_PATTERNS = [
 56    'Inspected target navigated or closed',
 57  ] as const;
 58  
 59  function errorMessage(err: unknown): string {
 60    return err instanceof Error ? err.message : String(err);
 61  }
 62  
 63  /**
 64   * Classify a browser error and return retry advice.
 65   *
 66   * Single source of truth for "is this error transient?" across all layers.
 67   */
 68  export function classifyBrowserError(err: unknown): RetryAdvice {
 69    const msg = errorMessage(err);
 70  
 71    // Extension/daemon transient errors — longer recovery time
 72    if (EXTENSION_TRANSIENT_PATTERNS.some(p => msg.includes(p))) {
 73      return { kind: 'extension-transient', retryable: true, delayMs: 1500 };
 74    }
 75  
 76    // CDP target navigation errors — shorter recovery time
 77    if (TARGET_NAVIGATION_PATTERNS.some(p => msg.includes(p))) {
 78      return { kind: 'target-navigation', retryable: true, delayMs: 200 };
 79    }
 80  
 81    // CDP protocol error with target context (e.g., -32000 "target closed")
 82    if (msg.includes('-32000') && msg.toLowerCase().includes('target')) {
 83      return { kind: 'target-navigation', retryable: true, delayMs: 200 };
 84    }
 85  
 86    return { kind: 'non-retryable', retryable: false, delayMs: 0 };
 87  }
 88  
 89  /**
 90   * Check if an error is a transient browser error worth retrying.
 91   * Convenience wrapper around classifyBrowserError().
 92   */
 93  export function isTransientBrowserError(err: unknown): boolean {
 94    return classifyBrowserError(err).retryable;
 95  }
 96  
 97  // Re-export so callers don't need to import from two places
 98  export type ConnectFailureKind = BrowserConnectKind;
 99  
100  export function formatBrowserConnectError(kind: ConnectFailureKind, detail?: string): BrowserConnectError {
101    switch (kind) {
102      case 'daemon-not-running':
103        return new BrowserConnectError(
104          'Cannot connect to opencli daemon.' + (detail ? `\n\n${detail}` : ''),
105          `The daemon should auto-start. If it keeps failing, make sure port ${DEFAULT_DAEMON_PORT} is available.`,
106          kind,
107        );
108      case 'extension-not-connected':
109        return new BrowserConnectError(
110          'Browser Bridge extension is not connected.' + (detail ? `\n\n${detail}` : ''),
111          'Install the extension from GitHub Releases, then reload.',
112          kind,
113        );
114      case 'command-failed':
115        return new BrowserConnectError(
116          `Browser command failed: ${detail ?? 'unknown error'}`,
117          undefined,
118          kind,
119        );
120      default:
121        return new BrowserConnectError(
122          detail ?? 'Failed to connect to browser',
123          undefined,
124          kind,
125        );
126    }
127  }