From 7cea7fbf9cc30ee89d348758d960ebcbceac5c57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20Bu=C4=9Fra=20Yi=C4=9Fiter?= Date: Fri, 6 Feb 2026 13:54:51 +0300 Subject: [PATCH 1/3] Refactor error handling across commands --- src/commands/apikeys.ts | 5 +- src/commands/indexer.ts | 9 +-- src/commands/login.ts | 63 +++++++++++++++- src/commands/projects.ts | 73 +++++++++++++++++-- src/commands/transfer.ts | 139 ++++++++++++++++++++++++++++++++++-- src/commands/wallet-info.ts | 3 +- src/lib/api.ts | 84 ++++++++++++++++++++-- src/lib/errors.ts | 27 +++++++ 8 files changed, 382 insertions(+), 21 deletions(-) create mode 100644 src/lib/errors.ts 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..c9d4929 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,65 @@ 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..e03b4c1 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -1,10 +1,66 @@ 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 +159,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 +292,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 +358,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..e776e9c 100644 --- a/src/commands/transfer.ts +++ b/src/commands/transfer.ts @@ -4,6 +4,20 @@ 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` +} // ERC20 ABI for transfer function const ERC20_ABI = [ @@ -25,8 +39,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 +110,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 +211,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 +252,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 +266,12 @@ export const transferCommand = new Command('transfer') amount, symbol, chainId, + timing: { + sessionMs, + sendMs, + confirmMs, + totalMs, + }, }, null, 2 @@ -233,6 +280,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,8 +289,67 @@ 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 + 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 common errors if (errorMessage.includes('insufficient') || errorMessage.includes('balance')) { @@ -252,6 +359,7 @@ export const transferCommand = new Command('transfer') error: 'Insufficient balance', walletAddress, code: EXIT_CODES.INSUFFICIENT_FUNDS, + timing: { failedStep: currentStep, failedStepMs, totalElapsedMs }, }) ) } else { @@ -266,8 +374,31 @@ export const transferCommand = new Command('transfer') process.exit(EXIT_CODES.INSUFFICIENT_FUNDS) } + // Check for 403/rate-limit in generic error messages (e.g. from Sequence SDK) + if (errorMessage.includes('403') || errorMessage.toLowerCase().includes('permissiondenied') || errorMessage.toLowerCase().includes('rate limit')) { + if (json) { + console.log( + JSON.stringify({ + error: 'Permission denied or rate limited', + detail: errorMessage, + walletAddress, + code: EXIT_CODES.API_ERROR, + timing: { failedStep: currentStep, failedStepMs, totalElapsedMs }, + }) + ) + } 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 attempts in a short period')) + 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: ${errorMessage}`)) + } + process.exit(EXIT_CODES.API_ERROR) + } + 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..f6359c6 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -46,6 +46,84 @@ 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 +151,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 +184,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) +} From ea1757a9e968f44ff591682e4b05e6582af2e20b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20Bu=C4=9Fra=20Yi=C4=9Fiter?= Date: Fri, 6 Feb 2026 13:56:10 +0300 Subject: [PATCH 2/3] Format --- src/commands/login.ts | 10 ++++++++-- src/commands/projects.ts | 13 ++++++++++--- src/commands/transfer.ts | 26 +++++++++++++++++--------- src/lib/api.ts | 15 ++++++++------- 4 files changed, 43 insertions(+), 21 deletions(-) diff --git a/src/commands/login.ts b/src/commands/login.ts index c9d4929..50cebbb 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -138,7 +138,9 @@ export const loginCommand = new Command('login') 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.') + chalk.gray( + ' You have made too many login attempts. Please wait before trying again.' + ) ) } else { console.error(chalk.red('✖ Permission denied (403)')) @@ -158,7 +160,11 @@ export const loginCommand = new Command('login') } // 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 ( + errorMessage.includes('403') || + errorMessage.toLowerCase().includes('permissiondenied') || + errorMessage.toLowerCase().includes('rate limit') + ) { if (options.json) { console.log( JSON.stringify({ diff --git a/src/commands/projects.ts b/src/commands/projects.ts index e03b4c1..d166c91 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -17,7 +17,10 @@ import { isValidPrivateKey } from '../lib/wallet.js' * 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 ( + isApiError(error) && + (error.isRateLimited || error.isPermissionDenied || error.isUnauthorized) + ) { if (json) { console.log( JSON.stringify({ @@ -38,7 +41,9 @@ function handleApiErrorOutput(error: unknown, json: boolean): boolean { 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.')) + 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:')) @@ -50,7 +55,9 @@ function handleApiErrorOutput(error: unknown, json: boolean): boolean { } } else { console.error(chalk.red('✖ Unauthorized (401)')) - console.error(chalk.gray(' Your JWT token may have expired. Re-run: sequence-builder login')) + 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}`)) diff --git a/src/commands/transfer.ts b/src/commands/transfer.ts index e776e9c..1af89cc 100644 --- a/src/commands/transfer.ts +++ b/src/commands/transfer.ts @@ -327,11 +327,11 @@ export const transferCommand = new Command('transfer') 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.yellow(` Retry after: ${error.retryAfterSeconds}s`)) } - console.error(chalk.gray(' You have made too many requests. Please wait before trying again.')) + 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:')) @@ -339,9 +339,7 @@ export const transferCommand = new Command('transfer') 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`) - ) + console.error(chalk.yellow(` Retry after: ${error.retryAfterSeconds}s`)) } } if (error.errorBody) { @@ -375,7 +373,11 @@ export const transferCommand = new Command('transfer') } // Check for 403/rate-limit in generic error messages (e.g. from Sequence SDK) - if (errorMessage.includes('403') || errorMessage.toLowerCase().includes('permissiondenied') || errorMessage.toLowerCase().includes('rate limit')) { + if ( + errorMessage.includes('403') || + errorMessage.toLowerCase().includes('permissiondenied') || + errorMessage.toLowerCase().includes('rate limit') + ) { if (json) { console.log( JSON.stringify({ @@ -398,7 +400,13 @@ export const transferCommand = new Command('transfer') } if (json) { - console.log(JSON.stringify({ error: errorMessage, code: EXIT_CODES.GENERAL_ERROR, timing: { failedStep: currentStep, failedStepMs, totalElapsedMs } })) + 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/lib/api.ts b/src/lib/api.ts index f6359c6..40b88bc 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -58,13 +58,14 @@ export class ApiError extends Error { 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` + const label = + statusCode === 429 + ? 'Rate Limited' + : statusCode === 403 + ? 'Permission Denied' + : statusCode === 401 + ? 'Unauthorized' + : `API Error` let detail = `${label} (${statusCode})` if (errorBody) { From cea99930212c43f8ce6d7438ef5851a696cebea7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20Bu=C4=9Fra=20Yi=C4=9Fiter?= Date: Fri, 6 Feb 2026 14:00:22 +0300 Subject: [PATCH 3/3] Improve rate limit error --- src/commands/transfer.ts | 114 +++++++++++++++++++++++++++++---------- 1 file changed, 86 insertions(+), 28 deletions(-) diff --git a/src/commands/transfer.ts b/src/commands/transfer.ts index 1af89cc..c72f710 100644 --- a/src/commands/transfer.ts +++ b/src/commands/transfer.ts @@ -19,6 +19,56 @@ function formatDuration(ms: number): string { 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 = [ 'function transfer(address to, uint256 amount) returns (bool)', @@ -309,7 +359,7 @@ export const transferCommand = new Command('transfer') ) } - // Check for rate-limit / permission errors + // Check for rate-limit / permission errors (structured ApiError from our api.ts) if (isApiError(error) && (error.isRateLimited || error.isPermissionDenied)) { if (json) { console.log( @@ -349,54 +399,62 @@ export const transferCommand = new Command('transfer') process.exit(EXIT_CODES.API_ERROR) } - // Check for common errors - if (errorMessage.includes('insufficient') || errorMessage.includes('balance')) { + // Check for rate-limit / permission in generic error messages (e.g. from Sequence SDK / ethers) + const detectedRateLimit = isRateLimitMessage(errorMessage) + const detectedPermission = !detectedRateLimit && isPermissionDeniedMessage(errorMessage) + + if (detectedRateLimit || detectedPermission) { + const cleanMsg = parseCleanErrorMessage(errorMessage) if (json) { console.log( JSON.stringify({ - error: 'Insufficient balance', + error: detectedRateLimit ? 'Rate limited' : 'Permission denied', + detail: cleanMsg, walletAddress, - code: EXIT_CODES.INSUFFICIENT_FUNDS, + code: EXIT_CODES.API_ERROR, timing: { failedStep: currentStep, failedStepMs, totalElapsedMs }, }) ) } else { - console.error(chalk.red('✖ Insufficient balance for transfer or gas fees')) - if (walletAddress) { - console.error(chalk.gray('Wallet address:'), chalk.cyan(walletAddress)) + 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}`)) } - console.error( - chalk.gray('Make sure your wallet has enough tokens and native currency for gas') - ) } - process.exit(EXIT_CODES.INSUFFICIENT_FUNDS) + process.exit(EXIT_CODES.API_ERROR) } - // Check for 403/rate-limit in generic error messages (e.g. from Sequence SDK) - if ( - errorMessage.includes('403') || - errorMessage.toLowerCase().includes('permissiondenied') || - errorMessage.toLowerCase().includes('rate limit') - ) { + // Check for insufficient balance + if (errorMessage.includes('insufficient') || errorMessage.includes('balance')) { if (json) { console.log( JSON.stringify({ - error: 'Permission denied or rate limited', - detail: errorMessage, + error: 'Insufficient balance', walletAddress, - code: EXIT_CODES.API_ERROR, + code: EXIT_CODES.INSUFFICIENT_FUNDS, timing: { failedStep: currentStep, failedStepMs, totalElapsedMs }, }) ) } 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 attempts in a short period')) - 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: ${errorMessage}`)) + console.error(chalk.red('✖ Insufficient balance for transfer or gas fees')) + if (walletAddress) { + console.error(chalk.gray('Wallet address:'), chalk.cyan(walletAddress)) + } + console.error( + chalk.gray('Make sure your wallet has enough tokens and native currency for gas') + ) } - process.exit(EXIT_CODES.API_ERROR) + process.exit(EXIT_CODES.INSUFFICIENT_FUNDS) } if (json) {