/ services / api / errorUtils.ts
errorUtils.ts
  1  import type { APIError } from '@anthropic-ai/sdk'
  2  
  3  // SSL/TLS error codes from OpenSSL (used by both Node.js and Bun)
  4  // See: https://www.openssl.org/docs/man3.1/man3/X509_STORE_CTX_get_error.html
  5  const SSL_ERROR_CODES = new Set([
  6    // Certificate verification errors
  7    'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
  8    'UNABLE_TO_GET_ISSUER_CERT',
  9    'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
 10    'CERT_SIGNATURE_FAILURE',
 11    'CERT_NOT_YET_VALID',
 12    'CERT_HAS_EXPIRED',
 13    'CERT_REVOKED',
 14    'CERT_REJECTED',
 15    'CERT_UNTRUSTED',
 16    // Self-signed certificate errors
 17    'DEPTH_ZERO_SELF_SIGNED_CERT',
 18    'SELF_SIGNED_CERT_IN_CHAIN',
 19    // Chain errors
 20    'CERT_CHAIN_TOO_LONG',
 21    'PATH_LENGTH_EXCEEDED',
 22    // Hostname/altname errors
 23    'ERR_TLS_CERT_ALTNAME_INVALID',
 24    'HOSTNAME_MISMATCH',
 25    // TLS handshake errors
 26    'ERR_TLS_HANDSHAKE_TIMEOUT',
 27    'ERR_SSL_WRONG_VERSION_NUMBER',
 28    'ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC',
 29  ])
 30  
 31  export type ConnectionErrorDetails = {
 32    code: string
 33    message: string
 34    isSSLError: boolean
 35  }
 36  
 37  /**
 38   * Extracts connection error details from the error cause chain.
 39   * The Anthropic SDK wraps underlying errors in the `cause` property.
 40   * This function walks the cause chain to find the root error code/message.
 41   */
 42  export function extractConnectionErrorDetails(
 43    error: unknown,
 44  ): ConnectionErrorDetails | null {
 45    if (!error || typeof error !== 'object') {
 46      return null
 47    }
 48  
 49    // Walk the cause chain to find the root error with a code
 50    let current: unknown = error
 51    const maxDepth = 5 // Prevent infinite loops
 52    let depth = 0
 53  
 54    while (current && depth < maxDepth) {
 55      if (
 56        current instanceof Error &&
 57        'code' in current &&
 58        typeof current.code === 'string'
 59      ) {
 60        const code = current.code
 61        const isSSLError = SSL_ERROR_CODES.has(code)
 62        return {
 63          code,
 64          message: current.message,
 65          isSSLError,
 66        }
 67      }
 68  
 69      // Move to the next cause in the chain
 70      if (
 71        current instanceof Error &&
 72        'cause' in current &&
 73        current.cause !== current
 74      ) {
 75        current = current.cause
 76        depth++
 77      } else {
 78        break
 79      }
 80    }
 81  
 82    return null
 83  }
 84  
 85  /**
 86   * Returns an actionable hint for SSL/TLS errors, intended for contexts outside
 87   * the main API client (OAuth token exchange, preflight connectivity checks)
 88   * where `formatAPIError` doesn't apply.
 89   *
 90   * Motivation: enterprise users behind TLS-intercepting proxies (Zscaler et al.)
 91   * see OAuth complete in-browser but the CLI's token exchange silently fails
 92   * with a raw SSL code. Surfacing the likely fix saves a support round-trip.
 93   */
 94  export function getSSLErrorHint(error: unknown): string | null {
 95    const details = extractConnectionErrorDetails(error)
 96    if (!details?.isSSLError) {
 97      return null
 98    }
 99    return `SSL certificate error (${details.code}). If you are behind a corporate proxy or TLS-intercepting firewall, set NODE_EXTRA_CA_CERTS to your CA bundle path, or ask IT to allowlist *.anthropic.com. Run /doctor for details.`
100  }
101  
102  /**
103   * Strips HTML content (e.g., CloudFlare error pages) from a message string,
104   * returning a user-friendly title or empty string if HTML is detected.
105   * Returns the original message unchanged if no HTML is found.
106   */
107  function sanitizeMessageHTML(message: string): string {
108    if (message.includes('<!DOCTYPE html') || message.includes('<html')) {
109      const titleMatch = message.match(/<title>([^<]+)<\/title>/)
110      if (titleMatch && titleMatch[1]) {
111        return titleMatch[1].trim()
112      }
113      return ''
114    }
115    return message
116  }
117  
118  /**
119   * Detects if an error message contains HTML content (e.g., CloudFlare error pages)
120   * and returns a user-friendly message instead
121   */
122  export function sanitizeAPIError(apiError: APIError): string {
123    const message = apiError.message
124    if (!message) {
125      // Sometimes message is undefined
126      // TODO: figure out why
127      return ''
128    }
129    return sanitizeMessageHTML(message)
130  }
131  
132  /**
133   * Shapes of deserialized API errors from session JSONL.
134   *
135   * After JSON round-tripping, the SDK's APIError loses its `.message` property.
136   * The actual message lives at different nesting levels depending on the provider:
137   *
138   * - Bedrock/proxy: `{ error: { message: "..." } }`
139   * - Standard Anthropic API: `{ error: { error: { message: "..." } } }`
140   *   (the outer `.error` is the response body, the inner `.error` is the API error)
141   *
142   * See also: `getErrorMessage` in `logging.ts` which handles the same shapes.
143   */
144  type NestedAPIError = {
145    error?: {
146      message?: string
147      error?: { message?: string }
148    }
149  }
150  
151  function hasNestedError(value: unknown): value is NestedAPIError {
152    return (
153      typeof value === 'object' &&
154      value !== null &&
155      'error' in value &&
156      typeof value.error === 'object' &&
157      value.error !== null
158    )
159  }
160  
161  /**
162   * Extract a human-readable message from a deserialized API error that lacks
163   * a top-level `.message`.
164   *
165   * Checks two nesting levels (deeper first for specificity):
166   * 1. `error.error.error.message` — standard Anthropic API shape
167   * 2. `error.error.message` — Bedrock shape
168   */
169  function extractNestedErrorMessage(error: APIError): string | null {
170    if (!hasNestedError(error)) {
171      return null
172    }
173  
174    // Access `.error` via the narrowed type so TypeScript sees the nested shape
175    // instead of the SDK's `Object | undefined`.
176    const narrowed: NestedAPIError = error
177    const nested = narrowed.error
178  
179    // Standard Anthropic API shape: { error: { error: { message } } }
180    const deepMsg = nested?.error?.message
181    if (typeof deepMsg === 'string' && deepMsg.length > 0) {
182      const sanitized = sanitizeMessageHTML(deepMsg)
183      if (sanitized.length > 0) {
184        return sanitized
185      }
186    }
187  
188    // Bedrock shape: { error: { message } }
189    const msg = nested?.message
190    if (typeof msg === 'string' && msg.length > 0) {
191      const sanitized = sanitizeMessageHTML(msg)
192      if (sanitized.length > 0) {
193        return sanitized
194      }
195    }
196  
197    return null
198  }
199  
200  export function formatAPIError(error: APIError): string {
201    // Extract connection error details from the cause chain
202    const connectionDetails = extractConnectionErrorDetails(error)
203  
204    if (connectionDetails) {
205      const { code, isSSLError } = connectionDetails
206  
207      // Handle timeout errors
208      if (code === 'ETIMEDOUT') {
209        return 'Request timed out. Check your internet connection and proxy settings'
210      }
211  
212      // Handle SSL/TLS errors with specific messages
213      if (isSSLError) {
214        switch (code) {
215          case 'UNABLE_TO_VERIFY_LEAF_SIGNATURE':
216          case 'UNABLE_TO_GET_ISSUER_CERT':
217          case 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY':
218            return 'Unable to connect to API: SSL certificate verification failed. Check your proxy or corporate SSL certificates'
219          case 'CERT_HAS_EXPIRED':
220            return 'Unable to connect to API: SSL certificate has expired'
221          case 'CERT_REVOKED':
222            return 'Unable to connect to API: SSL certificate has been revoked'
223          case 'DEPTH_ZERO_SELF_SIGNED_CERT':
224          case 'SELF_SIGNED_CERT_IN_CHAIN':
225            return 'Unable to connect to API: Self-signed certificate detected. Check your proxy or corporate SSL certificates'
226          case 'ERR_TLS_CERT_ALTNAME_INVALID':
227          case 'HOSTNAME_MISMATCH':
228            return 'Unable to connect to API: SSL certificate hostname mismatch'
229          case 'CERT_NOT_YET_VALID':
230            return 'Unable to connect to API: SSL certificate is not yet valid'
231          default:
232            return `Unable to connect to API: SSL error (${code})`
233        }
234      }
235    }
236  
237    if (error.message === 'Connection error.') {
238      // If we have a code but it's not SSL, include it for debugging
239      if (connectionDetails?.code) {
240        return `Unable to connect to API (${connectionDetails.code})`
241      }
242      return 'Unable to connect to API. Check your internet connection'
243    }
244  
245    // Guard: when deserialized from JSONL (e.g. --resume), the error object may
246    // be a plain object without a `.message` property.  Return a safe fallback
247    // instead of undefined, which would crash callers that access `.length`.
248    if (!error.message) {
249      return (
250        extractNestedErrorMessage(error) ??
251        `API error (status ${error.status ?? 'unknown'})`
252      )
253    }
254  
255    const sanitizedMessage = sanitizeAPIError(error)
256    // Use sanitized message if it's different from the original (i.e., HTML was sanitized)
257    return sanitizedMessage !== error.message && sanitizedMessage.length > 0
258      ? sanitizedMessage
259      : error.message
260  }