source dump of claude code
at main 260 lines 8.4 kB view raw
1import 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 5const 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 31export 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 */ 42export 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 */ 94export 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 */ 107function 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 */ 122export 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 */ 144type NestedAPIError = { 145 error?: { 146 message?: string 147 error?: { message?: string } 148 } 149} 150 151function 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 */ 169function 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 200export 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}