diff --git a/cli/lib.rs b/cli/lib.rs index c75bf54..1c8b826 100644 --- a/cli/lib.rs +++ b/cli/lib.rs @@ -1,4 +1,4 @@ -use std::{net::SocketAddr, time::Duration}; +use std::{fmt, io, net::SocketAddr, time::Duration}; use clap::{Parser, Subcommand}; use http::HeaderMap; @@ -8,6 +8,260 @@ use thunder::types::{Address, Txid}; use thunder_app_rpc_api::RpcClient; use tracing_subscriber::layer::SubscriberExt as _; +/// Custom error type for CLI-specific errors +#[derive(Debug)] +pub enum CliError { + /// Connection error with details + ConnectionError { + /// The URL that was being connected to + url: url::Url, + /// The underlying error + source: anyhow::Error, + }, + /// Other errors + Other(anyhow::Error), +} + +impl fmt::Display for CliError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ConnectionError { url, source } => { + // First, check if this is a DNS error by examining the full error chain + let is_dns_error = { + let err_str = source.to_string().to_lowercase(); + let is_dns = err_str.contains("dns error") || + err_str.contains("lookup") || + err_str.contains("nodename nor servname provided") || + err_str.contains("not known") || + err_str.contains("name resolution") || + err_str.contains("host not found") || + err_str.contains("no such host"); + + // Also check the error chain for DNS-related errors + if !is_dns { + let mut current_err = source.source(); + let mut found_dns_error = false; + while let Some(err) = current_err { + let err_str = err.to_string().to_lowercase(); + if err_str.contains("dns error") || + err_str.contains("lookup") || + err_str.contains("nodename nor servname provided") || + err_str.contains("not known") || + err_str.contains("name resolution") || + err_str.contains("host not found") || + err_str.contains("no such host") { + found_dns_error = true; + break; + } + current_err = err.source(); + } + found_dns_error + } else { + true + } + }; + + // Handle DNS errors first, as they're a special case + if is_dns_error { + write!(f, "Unable to connect: DNS resolution failed")?; + write!(f, "\n\nCould not resolve the host name: {}", url.host_str().unwrap_or("unknown"))?; + write!(f, "\n\nPlease check that:")?; + write!(f, "\n 1. The hostname part of the URL is correct")?; + write!(f, "\n 2. Your network connection is working")?; + write!(f, "\n 3. DNS resolution is functioning properly")?; + + return Ok(()); + } + + // Check for HTTP status code errors + let err_str = source.to_string().to_lowercase(); + if err_str.contains("404") || err_str.contains("rejected") { + write!(f, "Unable to connect: Server responded with an error")?; + write!(f, "\n\nThe server at {} responded, but it doesn't appear to be a Thunder node.", url)?; + write!(f, "\n\nPlease check that:")?; + write!(f, "\n 1. The URL is correct")?; + write!(f, "\n 2. The server at this address is running Thunder")?; + write!(f, "\n 3. You're using the correct protocol (http:// vs https://)")?; + + return Ok(()); + } + + // Check for connection closed errors + if err_str.contains("connection closed") || + err_str.contains("broken pipe") || + err_str.contains("reset by peer") { + write!(f, "Unable to connect: Connection was closed unexpectedly")?; + write!(f, "\n\nThe server at {} closed the connection.", url)?; + write!(f, "\n\nThis usually means:")?; + write!(f, "\n 1. The server is not a Thunder node")?; + write!(f, "\n 2. You're connecting to the wrong port")?; + write!(f, "\n 3. The server requires a different protocol")?; + write!(f, "\n\nPlease verify the address and port are correct.")?; + + return Ok(()); + } + + // Check for common connection errors and provide helpful messages + if let Some(io_err) = source.downcast_ref::() { + match io_err.kind() { + io::ErrorKind::ConnectionRefused => { + write!(f, "Unable to connect: Connection refused")?; + write!(f, "\n\nCould not connect to Thunder node at {}", url)?; + write!(f, "\n\nPlease check that:")?; + write!(f, "\n 1. The Thunder node is running")?; + write!(f, "\n 2. The RPC server is enabled")?; + write!(f, "\n 3. The RPC address is correct ({})", url)?; + write!(f, "\n 4. There are no firewall rules blocking the connection")?; + } + io::ErrorKind::ConnectionReset => { + write!(f, "Unable to connect: Connection reset")?; + write!(f, "\n\nThe connection was reset by the server at {}", url)?; + write!(f, "\n\nThis usually happens when:")?; + write!(f, "\n 1. The server is overloaded")?; + write!(f, "\n 2. The server is restarting")?; + write!(f, "\n 3. There's a network issue between you and the server")?; + write!(f, "\n\nPlease try again in a few moments.")?; + } + io::ErrorKind::ConnectionAborted => { + write!(f, "Unable to connect: Connection aborted")?; + write!(f, "\n\nThe connection was aborted while trying to reach {}", url)?; + write!(f, "\n\nThis usually happens when:")?; + write!(f, "\n 1. The server terminated the connection")?; + write!(f, "\n 2. A network device (like a firewall) interrupted the connection")?; + write!(f, "\n\nPlease check your network settings and try again.")?; + } + io::ErrorKind::TimedOut => { + write!(f, "Unable to connect: Connection timed out")?; + write!(f, "\n\nThe connection to {} timed out.", url)?; + write!(f, "\n\nThis usually means:")?; + write!(f, "\n 1. The server is not responding")?; + write!(f, "\n 2. A firewall is blocking the connection")?; + write!(f, "\n 3. The network path to the server is congested or down")?; + write!(f, "\n\nPlease check that the server is running and accessible.")?; + } + io::ErrorKind::AddrNotAvailable => { + write!(f, "Unable to connect: Address not available")?; + write!(f, "\n\nThe address {} cannot be used.", url)?; + write!(f, "\n\nThis usually happens when:")?; + write!(f, "\n 1. The IP address is not valid on this network")?; + write!(f, "\n 2. The port is already in use or reserved")?; + write!(f, "\n\nPlease try a different address or port.")?; + } + io::ErrorKind::NotConnected => { + write!(f, "Unable to connect: Not connected")?; + write!(f, "\n\nCould not establish a connection to {}", url)?; + write!(f, "\n\nPlease check your network connection and try again.")?; + } + io::ErrorKind::BrokenPipe => { + write!(f, "Unable to connect: Connection broken")?; + write!(f, "\n\nThe connection to {} was unexpectedly closed.", url)?; + write!(f, "\n\nThis usually happens when the server closes the connection.")?; + write!(f, "\n\nPlease check that the server is running and try again.")?; + } + _ => { + write!(f, "Unable to connect to Thunder node at {}", url)?; + write!(f, "\n\nAn unexpected network error occurred.")?; + write!(f, "\n\nPlease check that:")?; + write!(f, "\n 1. The Thunder node is running")?; + write!(f, "\n 2. The address is correct")?; + write!(f, "\n 3. Your network connection is working")?; + } + } + } else { + // Check for common error patterns in the error message + let err_str = source.to_string().to_lowercase(); + + if err_str.contains("connection refused") || + err_str.contains("tcp connect error") || + err_str.contains("connect error") || + err_str.contains("connect failed") || + err_str.contains("os error 61") || + err_str.contains("client error (connect)") { + write!(f, "Unable to connect: Connection refused")?; + write!(f, "\n\nCould not connect to Thunder node at {}", url)?; + write!(f, "\n\nPlease check that:")?; + write!(f, "\n 1. The Thunder node is running")?; + write!(f, "\n 2. The RPC server is enabled")?; + write!(f, "\n 3. The RPC address is correct ({})", url)?; + write!(f, "\n 4. There are no firewall rules blocking the connection")?; + } else if err_str.contains("timeout") || + err_str.contains("timed out") { + write!(f, "Unable to connect: Connection timed out")?; + write!(f, "\n\nThe connection to {} timed out.", url)?; + write!(f, "\n\nThis usually means:")?; + write!(f, "\n 1. The server is not responding")?; + write!(f, "\n 2. A firewall is blocking the connection")?; + write!(f, "\n 3. The network path to the server is congested or down")?; + write!(f, "\n\nPlease check that the server is running and accessible.")?; + } else { + // For other errors, provide a generic but user-friendly message + write!(f, "Unable to connect to Thunder node at {}", url)?; + write!(f, "\n\nAn unexpected error occurred while trying to connect.")?; + write!(f, "\n\nPlease check that:")?; + write!(f, "\n 1. The Thunder node is running")?; + write!(f, "\n 2. The address is correct")?; + write!(f, "\n 3. Your network connection is working")?; + + // If in verbose mode or for debugging, we can include the technical details + if std::env::var("THUNDER_VERBOSE").is_ok() { + write!(f, "\n\nTechnical details (for support):")?; + write!(f, "\n{}", source)?; + } + } + } + } + Self::Other(err) => { + // For other errors, we'll try to make them more user-friendly too + let err_str = err.to_string().to_lowercase(); + + if err_str.contains("404") || err_str.contains("rejected") { + write!(f, "Unable to connect: Server responded with an error")?; + write!(f, "\n\nThe server responded, but it doesn't appear to be a Thunder node.")?; + write!(f, "\n\nPlease check that:")?; + write!(f, "\n 1. The URL is correct")?; + write!(f, "\n 2. The server at this address is running Thunder")?; + write!(f, "\n 3. You're using the correct protocol (http:// vs https://)")?; + } else { + // For truly unknown errors, just pass through but with a nicer format + write!(f, "Error: {}", err)?; + + // If in verbose mode or for debugging, we can include more technical details + if std::env::var("THUNDER_VERBOSE").is_ok() { + if let Some(source) = err.source() { + write!(f, "\n\nCaused by: {}", source)?; + } + } + } + } + } + Ok(()) + } +} + +impl std::error::Error for CliError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::ConnectionError { source, .. } => Some(source.as_ref()), + Self::Other(err) => err.source(), + } + } +} + +impl From for CliError { + fn from(err: anyhow::Error) -> Self { + // Try to determine if this is a connection error + if let Some(client_err) = err.downcast_ref::() { + if client_err.to_string().contains("Connect") { + // This is likely a connection error, but we don't have the URL here + // We'll handle this in the run method where we have the URL + return Self::Other(err); + } + } + + Self::Other(err) + } +} + #[derive(Clone, Debug, Subcommand)] #[command(arg_required_else_help(true))] pub enum Command { @@ -95,7 +349,8 @@ pub enum Command { #[command(author, version, about, long_about = None)] pub struct Cli { /// Base URL used for requests to the RPC server. - #[arg(default_value = "http://localhost:6009", long)] + /// If protocol is not specified, http:// will be used. + #[arg(default_value = "http://localhost:6009", long, value_parser = parse_url)] pub rpc_url: url::Url, #[arg(long, help = "Timeout for RPC requests in seconds (default: 60)")] @@ -107,6 +362,73 @@ pub struct Cli { #[command(subcommand)] pub command: Command, } + +/// Custom URL parser that adds http:// prefix if missing +fn parse_url(s: &str) -> Result { + // Check if the URL already has a scheme + if s.contains("://") { + // Try to parse the URL as-is + match url::Url::parse(s) { + Ok(url) => Ok(url), + Err(e) => { + let error_msg = e.to_string().to_lowercase(); + + if error_msg.contains("invalid ip") || error_msg.contains("invalid ipv") { + Err(format!( + "Invalid IP address in URL: '{}'.\n\nPlease provide a valid IP address format.", + s + )) + } else if error_msg.contains("invalid port") { + Err(format!( + "Invalid port in URL: '{}'.\n\nPorts must be numbers between 1-65535.", + s + )) + } else if error_msg.contains("relative url") || error_msg.contains("empty host") { + Err(format!( + "Invalid URL format: '{}'.\n\nPlease provide a complete URL in the format: http://hostname:port", + s + )) + } else { + Err(format!( + "Invalid URL: '{}'.\n\nPlease provide a valid URL in the format: http://hostname:port", + s + )) + } + } + } + } else { + // Add http:// prefix and try to parse + let url_with_scheme = format!("http://{}", s); + match url::Url::parse(&url_with_scheme) { + Ok(url) => Ok(url), + Err(e) => { + let error_msg = e.to_string().to_lowercase(); + + if error_msg.contains("invalid ip") || error_msg.contains("invalid ipv") { + Err(format!( + "Invalid IP address: '{}'.\n\nPlease provide a valid IP address format.", + s + )) + } else if error_msg.contains("invalid port") { + Err(format!( + "Invalid port: '{}'.\n\nPorts must be numbers between 1-65535.", + s + )) + } else if error_msg.contains("relative url") || error_msg.contains("empty host") { + Err(format!( + "Invalid URL format: '{}'.\n\nPlease provide a complete URL in the format: hostname:port", + s + )) + } else { + Err(format!( + "Invalid URL: '{}'.\n\nPlease provide a valid URL in the format: hostname:port", + s + )) + } + } + } + } +} /// Handle a command, returning CLI output async fn handle_command( rpc_client: &RpcClient, @@ -253,14 +575,14 @@ fn set_tracing_subscriber() -> anyhow::Result<()> { } impl Cli { - pub async fn run(self) -> anyhow::Result { + pub async fn run(self) -> Result { if self.verbose { - set_tracing_subscriber()?; + set_tracing_subscriber().map_err(|e| CliError::Other(e))?; } const DEFAULT_TIMEOUT: u64 = 60; - let request_id = uuid::Uuid::new_v4().as_simple().to_string(); + let request_id = uuid::Uuid::new_v4().simple().to_string(); tracing::info!("request ID: {}", request_id); @@ -271,11 +593,74 @@ impl Cli { .set_max_logging_length(1024) .set_headers(HeaderMap::from_iter([( http::header::HeaderName::from_static("x-request-id"), - http::header::HeaderValue::from_str(&request_id)?, + http::header::HeaderValue::from_str(&request_id) + .map_err(|e| CliError::Other(e.into()))?, )])); - let client = builder.build(self.rpc_url)?; - let result = handle_command(&client, self.command).await?; + // Store the URL for potential error messages + let rpc_url = self.rpc_url.clone(); + + // Build the client and handle connection errors + let client = builder.build(self.rpc_url.clone()).map_err(|err| { + // Check if this is a connection error + let err_str = err.to_string().to_lowercase(); + if err_str.contains("connect") || + err_str.contains("connection") || + err_str.contains("network") || + err_str.contains("tcp") || + err_str.contains("dns") || + err_str.contains("lookup") || + err_str.contains("timeout") || + err_str.contains("timed out") { + return CliError::ConnectionError { + url: rpc_url.clone(), + source: err.into(), + }; + } + + CliError::Other(err.into()) + })?; + + // Execute the command and handle potential connection errors + let result = handle_command(&client, self.command) + .await + .map_err(|err| { + // Check if this is a connection error that happened during the request + if let Some(client_err) = err.downcast_ref::() { + if client_err.to_string().contains("Connect") || + client_err.to_string().contains("connection") || + client_err.to_string().contains("Connection") || + client_err.to_string().contains("network") || + client_err.to_string().contains("Network") || + client_err.to_string().contains("timeout") || + client_err.to_string().contains("timed out") { + return CliError::ConnectionError { + url: rpc_url.clone(), + source: err, + }; + } + } + + // Check for transport errors + let err_str = err.to_string().to_lowercase(); + if err_str.contains("tcp connect error") || + err_str.contains("connection refused") || + err_str.contains("connection reset") || + err_str.contains("connection aborted") || + err_str.contains("broken pipe") || + err_str.contains("dns error") || + err_str.contains("lookup") || + err_str.contains("timeout") || + err_str.contains("timed out") { + return CliError::ConnectionError { + url: rpc_url.clone(), + source: err, + }; + } + + CliError::Other(err) + })?; + Ok(result) } } diff --git a/cli/main.rs b/cli/main.rs index a2072c5..fcc63c8 100644 --- a/cli/main.rs +++ b/cli/main.rs @@ -1,13 +1,36 @@ use clap::Parser; -use thunder_app_cli_lib::Cli; +use thunder_app_cli_lib::{Cli, CliError}; #[tokio::main] async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); - let res = cli.run().await?; - #[allow(clippy::print_stdout)] - { - println!("{res}"); + + match cli.run().await { + Ok(res) => { + #[allow(clippy::print_stdout)] + { + println!("{res}"); + } + Ok(()) + } + Err(err) => { + // For all errors, we want to show a user-friendly message without the stack trace + // unless THUNDER_DEBUG is set + if std::env::var("THUNDER_DEBUG").is_ok() { + match err { + CliError::ConnectionError { .. } => { + eprintln!("{}", err); + std::process::exit(1); + } + CliError::Other(err) => { + // For other errors, we'll let anyhow handle it with the stack trace + Err(err) + } + } + } else { + eprintln!("{}", err); + std::process::exit(1); + } + } } - Ok(()) }