diff --git a/src/commands/apikeys.ts b/src/commands/apikeys.ts index 1bde78b..39d8120 100644 --- a/src/commands/apikeys.ts +++ b/src/commands/apikeys.ts @@ -2,6 +2,7 @@ import { Command } from 'commander' import chalk from 'chalk' import { listAccessKeys, getDefaultAccessKey } from '../lib/api.js' import { isLoggedIn, EXIT_CODES } from '../lib/config.js' +import { extractErrorMessage } from '../lib/errors.js' export const apikeysCommand = new Command('apikeys') .description('Manage API keys for a project') @@ -103,7 +104,7 @@ async function listApiKeysAction( console.log('') } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) + const errorMessage = extractErrorMessage(error) if (json) { console.log(JSON.stringify({ error: errorMessage, code: EXIT_CODES.API_ERROR })) } else { @@ -172,7 +173,7 @@ async function getDefaultKeyAction( ) console.log('') } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) + const errorMessage = extractErrorMessage(error) if (json) { console.log(JSON.stringify({ error: errorMessage, code: EXIT_CODES.API_ERROR })) } else { diff --git a/src/commands/indexer.ts b/src/commands/indexer.ts index 6d62b04..0039ce4 100644 --- a/src/commands/indexer.ts +++ b/src/commands/indexer.ts @@ -4,6 +4,7 @@ import { SequenceIndexer } from '@0xsequence/indexer' import { networks, ChainId } from '@0xsequence/network' import { EXIT_CODES } from '../lib/config.js' import { ethers } from 'ethers' +import { extractErrorMessage } from '../lib/errors.js' // Get indexer URL for a chain function getIndexerUrl(chainId: number): string { @@ -77,7 +78,7 @@ indexerCommand } console.log('') } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) + const errorMessage = extractErrorMessage(error) if (json) { console.log(JSON.stringify({ error: errorMessage, code: EXIT_CODES.GENERAL_ERROR })) } else { @@ -147,7 +148,7 @@ indexerCommand console.log(chalk.cyan(` ${symbol}:`), chalk.white(balance)) console.log('') } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) + const errorMessage = extractErrorMessage(error) if (json) { console.log(JSON.stringify({ error: errorMessage, code: EXIT_CODES.GENERAL_ERROR })) } else { @@ -237,7 +238,7 @@ indexerCommand console.log('') } } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) + const errorMessage = extractErrorMessage(error) if (json) { console.log(JSON.stringify({ error: errorMessage, code: EXIT_CODES.GENERAL_ERROR })) } else { @@ -327,7 +328,7 @@ indexerCommand } console.log('') } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) + const errorMessage = extractErrorMessage(error) if (json) { console.log(JSON.stringify({ error: errorMessage, code: EXIT_CODES.GENERAL_ERROR })) } else { diff --git a/src/commands/login.ts b/src/commands/login.ts index d7eb694..50cebbb 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,7 +1,8 @@ import { Command } from 'commander' import chalk from 'chalk' import { generateEthAuthProof } from '../lib/ethauth.js' -import { getAuthToken } from '../lib/api.js' +import { getAuthToken, isApiError } from '../lib/api.js' +import { extractErrorMessage } from '../lib/errors.js' import { updateConfig, EXIT_CODES, @@ -116,7 +117,71 @@ export const loginCommand = new Command('login') console.log(chalk.gray(' sequence-builder projects create "My Project"')) console.log('') } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) + const errorMessage = extractErrorMessage(error) + + // Structured API error with rate-limit / permission info + if (isApiError(error) && (error.isRateLimited || error.isPermissionDenied)) { + if (options.json) { + console.log( + JSON.stringify({ + error: error.isRateLimited ? 'Rate limited' : 'Permission denied', + statusCode: error.statusCode, + retryAfterSeconds: error.retryAfterSeconds, + detail: error.errorBody, + code: EXIT_CODES.API_ERROR, + }) + ) + } else { + if (error.isRateLimited) { + console.error(chalk.red('✖ Rate limited by the API')) + if (error.retryAfterSeconds !== null) { + console.error(chalk.yellow(` Retry after: ${error.retryAfterSeconds}s`)) + } + console.error( + chalk.gray( + ' You have made too many login attempts. Please wait before trying again.' + ) + ) + } else { + console.error(chalk.red('✖ Permission denied (403)')) + console.error(chalk.gray(' This can happen when:')) + console.error(chalk.gray(' - Too many signing/login attempts in a short period')) + console.error(chalk.gray(' - The ETHAuth proof is malformed or expired')) + console.error(chalk.gray(' - Your wallet address is not authorized')) + } + if (error.retryAfterSeconds !== null && !error.isRateLimited) { + console.error(chalk.yellow(` Retry after: ${error.retryAfterSeconds}s`)) + } + if (error.errorBody) { + console.error(chalk.gray(` Server response: ${error.errorBody}`)) + } + } + process.exit(EXIT_CODES.API_ERROR) + } + + // Catch 403/rate-limit in generic error strings (e.g. from SDK internals) + if ( + errorMessage.includes('403') || + errorMessage.toLowerCase().includes('permissiondenied') || + errorMessage.toLowerCase().includes('rate limit') + ) { + if (options.json) { + console.log( + JSON.stringify({ + error: 'Permission denied or rate limited', + detail: errorMessage, + code: EXIT_CODES.API_ERROR, + }) + ) + } else { + console.error(chalk.red('✖ Permission denied or rate limited')) + console.error(chalk.gray(' This can happen when:')) + console.error(chalk.gray(' - Too many signing/login attempts in a short period')) + console.error(chalk.gray(' - The ETHAuth proof is malformed or expired')) + console.error(chalk.gray(` Detail: ${errorMessage}`)) + } + process.exit(EXIT_CODES.API_ERROR) + } if (options.json) { console.log(JSON.stringify({ error: errorMessage, code: EXIT_CODES.API_ERROR })) diff --git a/src/commands/projects.ts b/src/commands/projects.ts index d36ef99..d166c91 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -1,10 +1,73 @@ import { Command } from 'commander' import chalk from 'chalk' import { Session } from '@0xsequence/auth' -import { listProjects, createProject, getProject, getDefaultAccessKey } from '../lib/api.js' +import { + listProjects, + createProject, + getProject, + getDefaultAccessKey, + isApiError, +} from '../lib/api.js' +import { extractErrorMessage } from '../lib/errors.js' import { isLoggedIn, EXIT_CODES, loadConfig } from '../lib/config.js' import { isValidPrivateKey } from '../lib/wallet.js' +/** + * Handle API errors with rate-limit / permission awareness. + * Returns true if the error was handled; false otherwise. + */ +function handleApiErrorOutput(error: unknown, json: boolean): boolean { + if ( + isApiError(error) && + (error.isRateLimited || error.isPermissionDenied || error.isUnauthorized) + ) { + if (json) { + console.log( + JSON.stringify({ + error: error.isRateLimited + ? 'Rate limited' + : error.isPermissionDenied + ? 'Permission denied' + : 'Unauthorized', + statusCode: error.statusCode, + retryAfterSeconds: error.retryAfterSeconds, + detail: error.errorBody, + code: EXIT_CODES.API_ERROR, + }) + ) + } else { + if (error.isRateLimited) { + console.error(chalk.red('✖ Rate limited by the API')) + if (error.retryAfterSeconds !== null) { + console.error(chalk.yellow(` Retry after: ${error.retryAfterSeconds}s`)) + } + console.error( + chalk.gray(' You have made too many requests. Please wait before trying again.') + ) + } else if (error.isPermissionDenied) { + console.error(chalk.red('✖ Permission denied (403)')) + console.error(chalk.gray(' This can happen when:')) + console.error(chalk.gray(' - Your session token has expired (re-run login)')) + console.error(chalk.gray(' - Too many requests in a short period')) + console.error(chalk.gray(' - The access key is invalid or revoked')) + if (error.retryAfterSeconds !== null) { + console.error(chalk.yellow(` Retry after: ${error.retryAfterSeconds}s`)) + } + } else { + console.error(chalk.red('✖ Unauthorized (401)')) + console.error( + chalk.gray(' Your JWT token may have expired. Re-run: sequence-builder login') + ) + } + if (error.errorBody) { + console.error(chalk.gray(` Server response: ${error.errorBody}`)) + } + } + return true + } + return false +} + export const projectsCommand = new Command('projects') .description('Manage Sequence Builder projects') .option('--json', 'Output in JSON format') @@ -103,7 +166,10 @@ async function listProjectsAction(options: { json?: boolean; env?: string; apiUr console.log(chalk.gray('Run `sequence-builder apikeys ` to view API keys')) console.log('') } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) + if (handleApiErrorOutput(error, !!json)) { + process.exit(EXIT_CODES.API_ERROR) + } + const errorMessage = extractErrorMessage(error) if (json) { console.log(JSON.stringify({ error: errorMessage, code: EXIT_CODES.API_ERROR })) } else { @@ -233,7 +299,10 @@ async function createProjectAction( } console.log('') } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) + if (handleApiErrorOutput(error, !!json)) { + process.exit(EXIT_CODES.API_ERROR) + } + const errorMessage = extractErrorMessage(error) if (json) { console.log(JSON.stringify({ error: errorMessage, code: EXIT_CODES.API_ERROR })) } else { @@ -296,7 +365,10 @@ async function getProjectAction( } console.log('') } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) + if (handleApiErrorOutput(error, !!json)) { + process.exit(EXIT_CODES.API_ERROR) + } + const errorMessage = extractErrorMessage(error) if (json) { console.log(JSON.stringify({ error: errorMessage, code: EXIT_CODES.API_ERROR })) } else { diff --git a/src/commands/transfer.ts b/src/commands/transfer.ts index a08b4f3..c72f710 100644 --- a/src/commands/transfer.ts +++ b/src/commands/transfer.ts @@ -4,6 +4,70 @@ import { ethers } from 'ethers' import { Session } from '@0xsequence/auth' import { EXIT_CODES, getPrivateKey } from '../lib/config.js' import { isValidPrivateKey } from '../lib/wallet.js' +import { isApiError } from '../lib/api.js' +import { extractErrorMessage } from '../lib/errors.js' + +/** + * Format a duration in milliseconds to a human-readable string. + * e.g. 350 -> "350ms", 1200 -> "1.2s", 65000 -> "1m 5s" + */ +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms` + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s` + const mins = Math.floor(ms / 60000) + const secs = Math.round((ms % 60000) / 1000) + return secs > 0 ? `${mins}m ${secs}s` : `${mins}m` +} + +/** Patterns that indicate a rate-limit or permission error in SDK/RPC messages */ +const RATE_LIMIT_PATTERNS = [ + '429', + 'too many requests', + 'rate limit', + 'retry limit', + 'rate is too high', + 'slow down', +] +const PERMISSION_DENIED_PATTERNS = ['403', 'permissiondenied', 'permission denied', 'forbidden'] + +function isRateLimitMessage(msg: string): boolean { + const lower = msg.toLowerCase() + return RATE_LIMIT_PATTERNS.some((p) => lower.includes(p)) +} + +function isPermissionDeniedMessage(msg: string): boolean { + const lower = msg.toLowerCase() + return PERMISSION_DENIED_PATTERNS.some((p) => lower.includes(p)) +} + +/** + * Try to extract a clean, human-readable message from ethers/SDK nested errors. + * These often embed JSON in an `info.responseBody` field like: + * `{"jsonrpc":"2.0","id":1,"error":{"code":1201,"message":"..."}}` + */ +function parseCleanErrorMessage(raw: string): string { + // Try to find a JSON "message" inside responseBody + const bodyMatch = raw.match(/"responseBody"\s*:\s*"((?:\\"|[^"])*)"/) + if (bodyMatch) { + try { + const body = JSON.parse(`"${bodyMatch[1]}"`) // unescape the string + const parsed = JSON.parse(body) + if (parsed?.error?.message) { + return parsed.error.message + } + } catch { + // fall through + } + } + + // Try to find responseStatus + const statusMatch = raw.match(/"responseStatus"\s*:\s*"([^"]*)"/) + if (statusMatch) { + return statusMatch[1] + } + + return raw +} // ERC20 ABI for transfer function const ERC20_ABI = [ @@ -25,8 +89,11 @@ export const transferCommand = new Command('transfer') .action(async (options) => { const { accessKey, token, recipient, amount, chainId: chainIdStr, json } = options - // Track wallet address for error reporting + // Track wallet address and timing for error reporting let walletAddress: string | undefined + const overallStart = performance.now() + let currentStep = 'initializing' + let stepStart = overallStart try { const privateKey = getPrivateKey(options) @@ -93,10 +160,18 @@ export const transferCommand = new Command('transfer') } // Create Sequence session + currentStep = 'creating session' + stepStart = performance.now() + const sessionStart = stepStart const session = await Session.singleSigner({ signer: normalizedPrivateKey, projectAccessKey: accessKey, }) + const sessionMs = Math.round(performance.now() - sessionStart) + + if (!json) { + console.log(chalk.gray('Session created'), chalk.gray(`(${formatDuration(sessionMs)})`)) + } // Get signer with fee options - automatically select first available fee token const signer = session.account.getSigner(chainId, { @@ -186,21 +261,35 @@ export const transferCommand = new Command('transfer') } // Populate the transaction + currentStep = 'preparing transaction' + stepStart = performance.now() const txn = await contract.transfer.populateTransaction(recipient, amountParsed) // Send the transaction if (!json) { console.log(chalk.gray('Submitting to relayer...')) } + currentStep = 'submitting to relayer' + stepStart = performance.now() + const sendStart = stepStart const txResponse = await signer.sendTransaction(txn) + const sendMs = Math.round(performance.now() - sendStart) if (!json) { - console.log(chalk.gray('Transaction submitted:'), chalk.cyan(txResponse.hash)) + console.log( + chalk.gray('Transaction submitted:'), + chalk.cyan(txResponse.hash), + chalk.gray(`(${formatDuration(sendMs)})`) + ) console.log(chalk.gray('Waiting for confirmation...')) } // Wait for the transaction to be mined (with timeout) + currentStep = 'waiting for confirmation' + stepStart = performance.now() + const confirmStart = stepStart const receipt = await txResponse.wait(undefined, 30000) + const confirmMs = Math.round(performance.now() - confirmStart) if (!receipt) { if (json) { @@ -213,6 +302,8 @@ export const transferCommand = new Command('transfer') process.exit(EXIT_CODES.GENERAL_ERROR) } + const totalMs = sessionMs + sendMs + confirmMs + if (json) { console.log( JSON.stringify( @@ -225,6 +316,12 @@ export const transferCommand = new Command('transfer') amount, symbol, chainId, + timing: { + sessionMs, + sendMs, + confirmMs, + totalMs, + }, }, null, 2 @@ -233,6 +330,7 @@ export const transferCommand = new Command('transfer') return } + console.log(chalk.gray('Confirmed'), chalk.gray(`(${formatDuration(confirmMs)})`)) console.log('') console.log(chalk.green.bold('✓ Transfer successful!')) console.log('') @@ -241,10 +339,102 @@ export const transferCommand = new Command('transfer') console.log(chalk.white('To: '), chalk.gray(recipient)) console.log(chalk.white('Amount: '), chalk.white(`${amount} ${symbol}`)) console.log('') + console.log( + chalk.gray( + `Timing: session ${formatDuration(sessionMs)}, send ${formatDuration(sendMs)}, confirm ${formatDuration(confirmMs)}, total ${formatDuration(totalMs)}` + ) + ) + console.log('') } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) + const errorMessage = extractErrorMessage(error) + const failedStepMs = Math.round(performance.now() - stepStart) + const totalElapsedMs = Math.round(performance.now() - overallStart) + + // Always show timing context for the failed step + if (!json) { + console.error( + chalk.gray( + `Failed during "${currentStep}" after ${formatDuration(failedStepMs)} (total elapsed: ${formatDuration(totalElapsedMs)})` + ) + ) + } + + // Check for rate-limit / permission errors (structured ApiError from our api.ts) + if (isApiError(error) && (error.isRateLimited || error.isPermissionDenied)) { + if (json) { + console.log( + JSON.stringify({ + error: error.isRateLimited ? 'Rate limited' : 'Permission denied', + statusCode: error.statusCode, + retryAfterSeconds: error.retryAfterSeconds, + detail: error.errorBody, + walletAddress, + code: EXIT_CODES.API_ERROR, + timing: { failedStep: currentStep, failedStepMs, totalElapsedMs }, + }) + ) + } else { + if (error.isRateLimited) { + console.error(chalk.red('✖ Rate limited by the API')) + if (error.retryAfterSeconds !== null) { + console.error(chalk.yellow(` Retry after: ${error.retryAfterSeconds}s`)) + } + console.error( + chalk.gray(' You have made too many requests. Please wait before trying again.') + ) + } else { + console.error(chalk.red('✖ Permission denied (403)')) + console.error(chalk.gray(' This can happen when:')) + console.error(chalk.gray(' - Your session or ETHAuth proof has expired')) + console.error(chalk.gray(' - You have exceeded the signing rate limit')) + console.error(chalk.gray(' - The access key is invalid or revoked')) + if (error.retryAfterSeconds !== null) { + console.error(chalk.yellow(` Retry after: ${error.retryAfterSeconds}s`)) + } + } + if (error.errorBody) { + console.error(chalk.gray(` Server response: ${error.errorBody}`)) + } + } + process.exit(EXIT_CODES.API_ERROR) + } + + // Check for rate-limit / permission in generic error messages (e.g. from Sequence SDK / ethers) + const detectedRateLimit = isRateLimitMessage(errorMessage) + const detectedPermission = !detectedRateLimit && isPermissionDeniedMessage(errorMessage) - // Check for common errors + if (detectedRateLimit || detectedPermission) { + const cleanMsg = parseCleanErrorMessage(errorMessage) + if (json) { + console.log( + JSON.stringify({ + error: detectedRateLimit ? 'Rate limited' : 'Permission denied', + detail: cleanMsg, + walletAddress, + code: EXIT_CODES.API_ERROR, + timing: { failedStep: currentStep, failedStepMs, totalElapsedMs }, + }) + ) + } else { + if (detectedRateLimit) { + console.error(chalk.red('✖ Rate limited — too many requests')) + console.error(chalk.yellow(` ${cleanMsg}`)) + console.error(chalk.gray(' Wait a minute or two before retrying.')) + console.error( + chalk.gray(' To increase limits, upgrade your project at https://sequence.build') + ) + } else { + console.error(chalk.red('✖ Permission denied')) + console.error(chalk.gray(' This can happen when:')) + console.error(chalk.gray(' - Your session or ETHAuth proof has expired')) + console.error(chalk.gray(' - The access key is invalid or revoked')) + console.error(chalk.gray(` Detail: ${cleanMsg}`)) + } + } + process.exit(EXIT_CODES.API_ERROR) + } + + // Check for insufficient balance if (errorMessage.includes('insufficient') || errorMessage.includes('balance')) { if (json) { console.log( @@ -252,6 +442,7 @@ export const transferCommand = new Command('transfer') error: 'Insufficient balance', walletAddress, code: EXIT_CODES.INSUFFICIENT_FUNDS, + timing: { failedStep: currentStep, failedStepMs, totalElapsedMs }, }) ) } else { @@ -267,7 +458,13 @@ export const transferCommand = new Command('transfer') } if (json) { - console.log(JSON.stringify({ error: errorMessage, code: EXIT_CODES.GENERAL_ERROR })) + console.log( + JSON.stringify({ + error: errorMessage, + code: EXIT_CODES.GENERAL_ERROR, + timing: { failedStep: currentStep, failedStepMs, totalElapsedMs }, + }) + ) } else { console.error(chalk.red('✖ Transfer failed:'), errorMessage) } diff --git a/src/commands/wallet-info.ts b/src/commands/wallet-info.ts index 425af86..be7dcdd 100644 --- a/src/commands/wallet-info.ts +++ b/src/commands/wallet-info.ts @@ -3,6 +3,7 @@ import chalk from 'chalk' import { Session } from '@0xsequence/auth' import { EXIT_CODES, getPrivateKey } from '../lib/config.js' import { isValidPrivateKey, getAddressFromPrivateKey } from '../lib/wallet.js' +import { extractErrorMessage } from '../lib/errors.js' export const walletInfoCommand = new Command('wallet-info') .description('Show wallet addresses (EOA and Sequence smart wallet)') @@ -87,7 +88,7 @@ export const walletInfoCommand = new Command('wallet-info') console.log(chalk.cyan.underline(fundingUrl)) console.log('') } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) + const errorMessage = extractErrorMessage(error) if (json) { console.log(JSON.stringify({ error: errorMessage, code: EXIT_CODES.GENERAL_ERROR })) } else { diff --git a/src/lib/api.ts b/src/lib/api.ts index 88cb3a9..40b88bc 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -46,6 +46,85 @@ export interface ListAccessKeysResponse { accessKeys: AccessKey[] } +/** + * Structured API error with status code, rate-limit info, and parsed details + */ +export class ApiError extends Error { + public readonly statusCode: number + public readonly retryAfterSeconds: number | null + public readonly errorBody: string + public readonly isRateLimited: boolean + public readonly isPermissionDenied: boolean + public readonly isUnauthorized: boolean + + constructor(statusCode: number, errorBody: string, retryAfter: number | null) { + const label = + statusCode === 429 + ? 'Rate Limited' + : statusCode === 403 + ? 'Permission Denied' + : statusCode === 401 + ? 'Unauthorized' + : `API Error` + + let detail = `${label} (${statusCode})` + if (errorBody) { + detail += `: ${errorBody}` + } + if (retryAfter !== null) { + detail += ` — retry after ${retryAfter}s` + } + + super(detail) + this.name = 'ApiError' + this.statusCode = statusCode + this.retryAfterSeconds = retryAfter + this.errorBody = errorBody + this.isRateLimited = statusCode === 429 + this.isPermissionDenied = statusCode === 403 + this.isUnauthorized = statusCode === 401 + } +} + +/** + * Check whether an error is an ApiError (useful in catch blocks) + */ +export function isApiError(error: unknown): error is ApiError { + return error instanceof ApiError +} + +/** + * Parse Retry-After header value into seconds. + * Supports both delta-seconds ("120") and HTTP-date formats. + */ +function parseRetryAfter(header: string | null): number | null { + if (!header) return null + + // Try as integer seconds first + const seconds = parseInt(header, 10) + if (!isNaN(seconds) && seconds >= 0) { + return seconds + } + + // Try as HTTP-date (e.g. "Fri, 06 Feb 2026 12:00:00 GMT") + const date = new Date(header) + if (!isNaN(date.getTime())) { + const delta = Math.max(0, Math.ceil((date.getTime() - Date.now()) / 1000)) + return delta + } + + return null +} + +/** + * Build an ApiError from a failed fetch Response + */ +async function buildApiError(response: Response): Promise { + const errorText = await response.text() + const retryAfter = parseRetryAfter(response.headers.get('Retry-After')) + return new ApiError(response.status, errorText, retryAfter) +} + /** * Make an API request to the Builder API */ @@ -73,8 +152,7 @@ async function apiRequest( }) if (!response.ok) { - const errorText = await response.text() - throw new Error(`API Error (${response.status}): ${errorText}`) + throw await buildApiError(response) } return response.json() as Promise @@ -107,8 +185,7 @@ async function quotaApiRequest( }) if (!response.ok) { - const errorText = await response.text() - throw new Error(`API Error (${response.status}): ${errorText}`) + throw await buildApiError(response) } return response.json() as Promise diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..9ef68a6 --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,27 @@ +/** + * Extract a readable error message from an unknown thrown value. + * Handles Error instances, plain objects with message/error/reason fields + * (common in SDK responses), and falls back to JSON.stringify for arbitrary objects. + */ +export function extractErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message + } + if (typeof error === 'string') { + return error + } + if (error && typeof error === 'object') { + // Common SDK error shapes: { message, error, reason, code, status } + const obj = error as Record + if (typeof obj.message === 'string') return obj.message + if (typeof obj.error === 'string') return obj.error + if (typeof obj.reason === 'string') return obj.reason + // Last resort: serialize the whole object + try { + return JSON.stringify(error, null, 2) + } catch { + return Object.prototype.toString.call(error) + } + } + return String(error) +}