diff --git a/.github/workflows/cont_integration.yml b/.github/workflows/cont_integration.yml index 0576bcde..e695c347 100644 --- a/.github/workflows/cont_integration.yml +++ b/.github/workflows/cont_integration.yml @@ -18,14 +18,14 @@ jobs: - default - blocking - blocking-https - - blocking-https-rustls - blocking-https-native - - blocking-https-bundled + - blocking-https-rustls + - blocking-https-rustls-probe - async - async-https - async-https-native - async-https-rustls - - async-https-rustls-manual-roots + - async-https-rustls-probe steps: - name: Checkout uses: actions/checkout@v4 diff --git a/Cargo.toml b/Cargo.toml index c693c461..1c666686 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,27 +25,26 @@ serde_json = { version = "1.0", default-features = false } bitcoin = { version = "0.32", features = ["serde", "std"], default-features = false } hex = { version = "0.2", package = "hex-conservative" } log = "^0.4" -minreq = { version = "2.11.0", features = ["json-using-serde"], optional = true } -reqwest = { version = "0.12", features = ["json"], default-features = false, optional = true } +bitreq = { version = "0.3.4", optional = true } # default async runtime tokio = { version = "1", features = ["time"], optional = true } [dev-dependencies] tokio = { version = "1.20.1", features = ["full"] } -electrsd = { version = "0.36.1", features = ["legacy", "esplora_a33e97e1", "corepc-node_29_0"] } +electrsd = { version = "0.38.0", features = ["legacy", "esplora_a33e97e1", "bitcoind_download", "bitcoind_29_0"] } [features] -default = ["blocking", "async", "async-https", "tokio"] -blocking = ["minreq", "minreq/proxy"] -blocking-https = ["blocking", "minreq/https"] -blocking-https-rustls = ["blocking", "minreq/https-rustls"] -blocking-https-native = ["blocking", "minreq/https-native"] -blocking-https-bundled = ["blocking", "minreq/https-bundled"] +default = ["blocking", "blocking-https", "async", "async-https", "tokio"] +blocking = ["bitreq/proxy", "bitreq/json-using-serde"] +blocking-https = ["blocking", "bitreq/https"] +blocking-https-native = ["blocking", "bitreq/https-native-tls"] +blocking-https-rustls = ["blocking", "bitreq/https-rustls"] +blocking-https-rustls-probe = ["blocking", "bitreq/https-rustls-probe"] tokio = ["dep:tokio"] -async = ["reqwest", "reqwest/socks", "tokio?/time"] -async-https = ["async", "reqwest/default-tls"] -async-https-native = ["async", "reqwest/native-tls"] -async-https-rustls = ["async", "reqwest/rustls-tls"] -async-https-rustls-manual-roots = ["async", "reqwest/rustls-tls-manual-roots"] +async = ["bitreq/async", "bitreq/proxy", "bitreq/json-using-serde", "tokio?/time"] +async-https = ["async", "bitreq/async-https"] +async-https-native = ["async", "bitreq/async-https-native-tls"] +async-https-rustls = ["async", "bitreq/async-https-rustls"] +async-https-rustls-probe = ["async", "bitreq/async-https-rustls-probe"] diff --git a/ci/pin-msrv.sh b/ci/pin-msrv.sh index 7cd9d6e1..3fce6f42 100644 --- a/ci/pin-msrv.sh +++ b/ci/pin-msrv.sh @@ -4,9 +4,5 @@ set -x set -euo pipefail # Pin dependencies for MSRV (1.75.0) -cargo update -p minreq --precise "2.13.2" cargo update -p native-tls --precise "0.2.13" -cargo update -p idna_adapter --precise "1.2.0" -cargo update -p zerofrom --precise "0.1.5" -cargo update -p litemap --precise "0.7.4" -cargo update -p hyper-rustls --precise "0.27.7" +cargo update -p "getrandom@0.4.2" --precise "0.3.4" diff --git a/src/async.rs b/src/async.rs index d960532d..12b16d2e 100644 --- a/src/async.rs +++ b/src/async.rs @@ -3,6 +3,7 @@ //! Esplora by way of `reqwest` HTTP client. use std::collections::{HashMap, HashSet}; +use std::fmt::Debug; use std::marker::PhantomData; use std::str::FromStr; use std::time::Duration; @@ -14,26 +15,30 @@ use bitcoin::hashes::{sha256, Hash}; use bitcoin::hex::{DisplayHex, FromHex}; use bitcoin::{Address, Block, BlockHash, MerkleBlock, Script, Transaction, Txid}; -#[allow(unused_imports)] -use log::{debug, error, info, trace}; - -use reqwest::{header, Body, Client, Response}; +use bitreq::{Client, Method, Proxy, Request, RequestExt, Response}; use crate::{ - AddressStats, BlockInfo, BlockStatus, BlockSummary, Builder, Error, MempoolRecentTx, - MempoolStats, MerkleProof, OutputStatus, ScriptHashStats, SubmitPackageResult, Tx, TxStatus, - Utxo, BASE_BACKOFF_MILLIS, RETRYABLE_ERROR_CODES, + is_retryable, is_success, AddressStats, BlockInfo, BlockStatus, BlockSummary, Builder, Error, + MempoolRecentTx, MempoolStats, MerkleProof, OutputStatus, ScriptHashStats, SubmitPackageResult, + Tx, TxStatus, Utxo, BASE_BACKOFF_MILLIS, }; /// An async client for interacting with an Esplora API server. -#[derive(Debug, Clone)] +// FIXME: (@oleonardolima) there's no `Debug` implementation for `bitreq::Client`. +#[derive(Clone)] pub struct AsyncClient { /// The URL of the Esplora Server. url: String, - /// The inner [`reqwest::Client`] to make HTTP requests. - client: Client, + /// The proxy is ignored when targeting `wasm32`. + proxy: Option, + /// Socket timeout. + timeout: Option, + /// HTTP headers to set on every request made to Esplora server + headers: HashMap, /// Number of times to retry a request max_retries: usize, + /// The inner [`bitreq::Client`] HTTP client to cache connections. + client: Client, /// Marker for the type of sleeper used marker: PhantomData, } @@ -41,45 +46,65 @@ pub struct AsyncClient { impl AsyncClient { /// Build an [`AsyncClient`] from a [`Builder`]. pub fn from_builder(builder: Builder) -> Result { - let mut client_builder = Client::builder(); + Ok(AsyncClient { + url: builder.base_url, + proxy: builder.proxy, + timeout: builder.timeout, + headers: builder.headers, + max_retries: builder.max_retries, + client: Client::new(builder.max_connections), + marker: PhantomData, + }) + } + + /// Get the underlying base URL. + pub fn url(&self) -> &str { + &self.url + } + + /// Get the underlying [`Client`]. + pub fn client(&self) -> &Client { + &self.client + } + + /// Build a HTTP [`Request`] with given [`Method`] and URI `path`. + pub(crate) fn build_request(&self, method: Method, path: &str) -> Result { + let mut request = Request::new(method, format!("{}{}", self.url, path)); #[cfg(not(target_arch = "wasm32"))] - if let Some(proxy) = &builder.proxy { - client_builder = client_builder.proxy(reqwest::Proxy::all(proxy)?); + if let Some(proxy) = &self.proxy { + request = request.with_proxy(Proxy::new_http(proxy)?); } #[cfg(not(target_arch = "wasm32"))] - if let Some(timeout) = builder.timeout { - client_builder = client_builder.timeout(core::time::Duration::from_secs(timeout)); + if let Some(timeout) = &self.timeout { + request = request.with_timeout(*timeout); } - if !builder.headers.is_empty() { - let mut headers = header::HeaderMap::new(); - for (k, v) in builder.headers { - let header_name = header::HeaderName::from_lowercase(k.to_lowercase().as_bytes()) - .map_err(|_| Error::InvalidHttpHeaderName(k))?; - let header_value = header::HeaderValue::from_str(&v) - .map_err(|_| Error::InvalidHttpHeaderValue(v))?; - headers.insert(header_name, header_value); - } - client_builder = client_builder.default_headers(headers); + if !self.headers.is_empty() { + request = request.with_headers(&self.headers); } - Ok(AsyncClient { - url: builder.base_url, - client: client_builder.build()?, - max_retries: builder.max_retries, - marker: PhantomData, - }) + Ok(request) } - /// Build an [`AsyncClient`] from a [`Client`]. - pub fn from_client(url: String, client: Client) -> Self { - AsyncClient { - url, - client, - max_retries: crate::DEFAULT_MAX_RETRIES, - marker: PhantomData, + /// Sends a GET request to the given `url`, retrying failed attempts + /// for retryable error codes until max retries hit. + async fn get_with_retry(&self, path: &str) -> Result { + let mut delay = BASE_BACKOFF_MILLIS; + let mut attempts = 0; + + let request = self.build_request(Method::Get, path)?; + + loop { + match request.clone().send_async_with_client(&self.client).await? { + response if attempts < self.max_retries && is_retryable(&response) => { + S::sleep(delay).await; + attempts += 1; + delay *= 2; + } + response => return Ok(response), + } } } @@ -95,17 +120,15 @@ impl AsyncClient { /// This function will return an error either from the HTTP client, or the /// [`bitcoin::consensus::Decodable`] deserialization. async fn get_response(&self, path: &str) -> Result { - let url = format!("{}{}", self.url, path); - let response = self.get_with_retry(&url).await?; - - if !response.status().is_success() { - return Err(Error::HttpResponse { - status: response.status().as_u16(), - message: response.text().await?, - }); + let response = self.get_with_retry(path).await?; + + if !is_success(&response) { + let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?; + let message = response.as_str().unwrap_or_default().to_string(); + return Err(Error::HttpResponse { status, message }); } - Ok(deserialize::(&response.bytes().await?)?) + Ok(deserialize::(response.as_bytes())?) } /// Make an HTTP GET request to given URL, deserializing to `Option`. @@ -135,17 +158,15 @@ impl AsyncClient { &self, path: &str, ) -> Result { - let url = format!("{}{}", self.url, path); - let response = self.get_with_retry(&url).await?; - - if !response.status().is_success() { - return Err(Error::HttpResponse { - status: response.status().as_u16(), - message: response.text().await?, - }); + let response = self.get_with_retry(path).await?; + + if !is_success(&response) { + let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?; + let message = response.as_str().unwrap_or_default().to_string(); + return Err(Error::HttpResponse { status, message }); } - response.json::().await.map_err(Error::Reqwest) + response.json::().map_err(Error::BitReq) } /// Make an HTTP GET request to given URL, deserializing to `Option`. @@ -177,18 +198,16 @@ impl AsyncClient { /// This function will return an error either from the HTTP client, or the /// [`bitcoin::consensus::Decodable`] deserialization. async fn get_response_hex(&self, path: &str) -> Result { - let url = format!("{}{}", self.url, path); - let response = self.get_with_retry(&url).await?; - - if !response.status().is_success() { - return Err(Error::HttpResponse { - status: response.status().as_u16(), - message: response.text().await?, - }); + let response = self.get_with_retry(path).await?; + + if !is_success(&response) { + let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?; + let message = response.as_str().unwrap_or_default().to_string(); + return Err(Error::HttpResponse { status, message }); } - let hex_str = response.text().await?; - Ok(deserialize(&Vec::from_hex(&hex_str)?)?) + let hex_str = response.as_str()?; + Ok(deserialize(&Vec::from_hex(hex_str)?)?) } /// Make an HTTP GET request to given URL, deserializing to `Option`. @@ -214,17 +233,15 @@ impl AsyncClient { /// /// This function will return an error either from the HTTP client. async fn get_response_text(&self, path: &str) -> Result { - let url = format!("{}{}", self.url, path); - let response = self.get_with_retry(&url).await?; - - if !response.status().is_success() { - return Err(Error::HttpResponse { - status: response.status().as_u16(), - message: response.text().await?, - }); + let response = self.get_with_retry(path).await?; + + if !is_success(&response) { + let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?; + let message = response.as_str().unwrap_or_default().to_string(); + return Err(Error::HttpResponse { status, message }); } - Ok(response.text().await?) + Ok(response.as_str()?.to_string()) } /// Make an HTTP GET request to given URL, deserializing to `Option`. @@ -248,26 +265,24 @@ impl AsyncClient { /// /// This function will return an error either from the HTTP client, or the /// response's [`serde_json`] deserialization. - async fn post_request_bytes>( + async fn post_request_bytes>>( &self, path: &str, body: T, query_params: Option>, ) -> Result { - let url: String = format!("{}{}", self.url, path); - let mut request = self.client.post(url).body(body); + let mut request: bitreq::Request = self.build_request(Method::Post, path)?.with_body(body); - for param in query_params.unwrap_or_default() { - request = request.query(¶m); + for (key, value) in query_params.unwrap_or_default() { + request = request.with_param(key, value); } - let response = request.send().await?; + let response = request.send_async_with_client(&self.client).await?; - if !response.status().is_success() { - return Err(Error::HttpResponse { - status: response.status().as_u16(), - message: response.text().await?, - }); + if !is_success(&response) { + let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?; + let message = response.as_str().unwrap_or_default().to_string(); + return Err(Error::HttpResponse { status, message }); } Ok(response) @@ -366,7 +381,7 @@ impl AsyncClient { pub async fn broadcast(&self, transaction: &Transaction) -> Result { let body = serialize::(transaction).to_lower_hex_string(); let response = self.post_request_bytes("/tx", body, None).await?; - let txid = Txid::from_str(&response.text().await?).map_err(Error::HexToArray)?; + let txid = Txid::from_str(response.as_str()?).map_err(Error::HexToArray)?; Ok(txid) } @@ -405,7 +420,7 @@ impl AsyncClient { ) .await?; - Ok(response.json::().await?) + Ok(response.json::()?) } /// Get the current height of the blockchain tip @@ -580,38 +595,6 @@ impl AsyncClient { self.get_response_json(&path).await } - - /// Get the underlying base URL. - pub fn url(&self) -> &str { - &self.url - } - - /// Get the underlying [`Client`]. - pub fn client(&self) -> &Client { - &self.client - } - - /// Sends a GET request to the given `url`, retrying failed attempts - /// for retryable error codes until max retries hit. - async fn get_with_retry(&self, url: &str) -> Result { - let mut delay = BASE_BACKOFF_MILLIS; - let mut attempts = 0; - - loop { - match self.client.get(url).send().await? { - resp if attempts < self.max_retries && is_status_retryable(resp.status()) => { - S::sleep(delay).await; - attempts += 1; - delay *= 2; - } - resp => return Ok(resp), - } - } - } -} - -fn is_status_retryable(status: reqwest::StatusCode) -> bool { - RETRYABLE_ERROR_CODES.contains(&status.as_u16()) } /// Sleeper trait that allows any async runtime to be used. diff --git a/src/blocking.rs b/src/blocking.rs index a64d68b6..9897929c 100644 --- a/src/blocking.rs +++ b/src/blocking.rs @@ -2,17 +2,16 @@ //! Esplora by way of `minreq` HTTP client. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::convert::TryFrom; use std::str::FromStr; use std::thread; use bitcoin::consensus::encode::serialize_hex; +use bitreq::{Method, Proxy, Request, Response}; #[allow(unused_imports)] use log::{debug, error, info, trace}; -use minreq::{Proxy, Request, Response}; - use bitcoin::block::Header as BlockHeader; use bitcoin::consensus::{deserialize, serialize, Decodable}; use bitcoin::hashes::{sha256, Hash}; @@ -20,9 +19,9 @@ use bitcoin::hex::{DisplayHex, FromHex}; use bitcoin::{Address, Block, BlockHash, MerkleBlock, Script, Transaction, Txid}; use crate::{ - AddressStats, BlockInfo, BlockStatus, BlockSummary, Builder, Error, MempoolRecentTx, - MempoolStats, MerkleProof, OutputStatus, ScriptHashStats, SubmitPackageResult, Tx, TxStatus, - Utxo, BASE_BACKOFF_MILLIS, RETRYABLE_ERROR_CODES, + is_retryable, is_success, AddressStats, BlockInfo, BlockStatus, BlockSummary, Builder, Error, + MempoolRecentTx, MempoolStats, MerkleProof, OutputStatus, ScriptHashStats, SubmitPackageResult, + Tx, TxStatus, Utxo, BASE_BACKOFF_MILLIS, }; /// A blocking client for interacting with an Esplora API server. @@ -57,13 +56,12 @@ impl BlockingClient { &self.url } - /// Perform a raw HTTP GET request with the given URI `path`. - pub fn get_request(&self, path: &str) -> Result { - let mut request = minreq::get(format!("{}{}", self.url, path)); + /// Build a HTTP [`Request`] with given [`Method`] and URI `path`. + pub(crate) fn build_request(&self, method: Method, path: &str) -> Result { + let mut request = Request::new(method, format!("{}{}", self.url, path)); if let Some(proxy) = &self.proxy { - let proxy = Proxy::new(proxy.as_str())?; - request = request.with_proxy(proxy); + request = request.with_proxy(Proxy::new_http(proxy)?); } if let Some(timeout) = &self.timeout { @@ -71,135 +69,206 @@ impl BlockingClient { } if !self.headers.is_empty() { - for (key, value) in &self.headers { - request = request.with_header(key, value); - } + request = request.with_headers(&self.headers); } Ok(request) } - fn post_request(&self, path: &str, body: T) -> Result - where - T: Into>, - { - let mut request = minreq::post(format!("{}{}", self.url, path)).with_body(body); + /// Make an HTTP POST request to given URL, converting any `T` that + /// implement [`Into`] and setting query parameters, if any. + /// + /// # Errors + /// + /// This function will return an error either from the HTTP client, or the + /// response's [`serde_json`] deserialization. + pub fn post_request>>( + &self, + path: &str, + body: T, + query_params: Option>, + ) -> Result { + let mut request = self.build_request(Method::Post, path)?.with_body(body); - if let Some(proxy) = &self.proxy { - let proxy = Proxy::new(proxy.as_str())?; - request = request.with_proxy(proxy); + for (key, value) in query_params.unwrap_or_default() { + request = request.with_param(key, value); } - if let Some(timeout) = &self.timeout { - request = request.with_timeout(*timeout); + let response = request.send()?; + + if !is_success(&response) { + let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?; + let message = response.as_str().unwrap_or_default().to_string(); + return Err(Error::HttpResponse { status, message }); } - Ok(request) + Ok(response) } - fn get_opt_response(&self, path: &str) -> Result, Error> { - match self.get_with_retry(path) { - Ok(resp) if is_status_not_found(resp.status_code) => Ok(None), - Ok(resp) if !is_status_ok(resp.status_code) => { - let status = u16::try_from(resp.status_code).map_err(Error::StatusCode)?; - let message = resp.as_str().unwrap_or_default().to_string(); - Err(Error::HttpResponse { status, message }) + /// Makes a HTTP GET request to the given `url`, retrying failed attempts + /// for retryable error codes until max retries hit. + fn get_with_retry(&self, url: &str) -> Result { + let mut delay = BASE_BACKOFF_MILLIS; + let mut attempts = 0; + + loop { + match self.build_request(Method::Get, url)?.send()? { + resp if attempts < self.max_retries && is_retryable(&resp) => { + thread::sleep(delay); + attempts += 1; + delay *= 2; + } + resp => return Ok(resp), } - Ok(resp) => Ok(Some(deserialize::(resp.as_bytes())?)), - Err(e) => Err(e), } } - fn get_opt_response_txid(&self, path: &str) -> Result, Error> { - match self.get_with_retry(path) { - Ok(resp) if is_status_not_found(resp.status_code) => Ok(None), - Ok(resp) if !is_status_ok(resp.status_code) => { - let status = u16::try_from(resp.status_code).map_err(Error::StatusCode)?; - let message = resp.as_str().unwrap_or_default().to_string(); - Err(Error::HttpResponse { status, message }) - } - Ok(resp) => Ok(Some( - Txid::from_str(resp.as_str().map_err(Error::Minreq)?).map_err(Error::HexToArray)?, - )), - Err(e) => Err(e), + /// Make an HTTP GET request to given URL, deserializing to any `T` that + /// implement [`bitcoin::consensus::Decodable`]. + /// + /// It should be used when requesting Esplora endpoints that can be directly + /// deserialized to native `rust-bitcoin` types, which implements + /// [`bitcoin::consensus::Decodable`] from `&[u8]`. + /// + /// # Errors + /// + /// This function will return an error either from the HTTP client, or the + /// [`bitcoin::consensus::Decodable`] deserialization. + fn get_response(&self, path: &str) -> Result { + let response = self.get_with_retry(path)?; + + if !is_success(&response) { + let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?; + let message = response.as_str().unwrap_or_default().to_string(); + return Err(Error::HttpResponse { status, message }); } + + Ok(deserialize::(response.as_bytes())?) } - fn get_opt_response_hex(&self, path: &str) -> Result, Error> { - match self.get_with_retry(path) { - Ok(resp) if is_status_not_found(resp.status_code) => Ok(None), - Ok(resp) if !is_status_ok(resp.status_code) => { - let status = u16::try_from(resp.status_code).map_err(Error::StatusCode)?; - let message = resp.as_str().unwrap_or_default().to_string(); - Err(Error::HttpResponse { status, message }) - } - Ok(resp) => { - let hex_str = resp.as_str().map_err(Error::Minreq)?; - let hex_vec = Vec::from_hex(hex_str)?; - deserialize::(&hex_vec) - .map_err(Error::BitcoinEncoding) - .map(|r| Some(r)) - } + /// Make an HTTP GET request to given URL, deserializing to `Option`. + /// + /// It uses [`BlockingClient::get_response`] internally. + /// + /// See [`BlockingClient::get_response`] above for full documentation. + fn get_opt_response(&self, path: &str) -> Result, Error> { + match self.get_response(path) { + Ok(response) => Ok(Some(response)), + Err(Error::HttpResponse { status: 404, .. }) => Ok(None), Err(e) => Err(e), } } + /// Make an HTTP GET request to given URL, deserializing to any `T` that + /// implements [`bitcoin::consensus::Decodable`]. + /// + /// It should be used when requesting Esplora endpoints that are expected + /// to return a hex string decodable to native `rust-bitcoin` types which + /// implement [`bitcoin::consensus::Decodable`] from `&[u8]`. + /// + /// # Errors + /// + /// This function will return an error either from the HTTP client, or the + /// [`bitcoin::consensus::Decodable`] deserialization. fn get_response_hex(&self, path: &str) -> Result { - match self.get_with_retry(path) { - Ok(resp) if !is_status_ok(resp.status_code) => { - let status = u16::try_from(resp.status_code).map_err(Error::StatusCode)?; - let message = resp.as_str().unwrap_or_default().to_string(); - Err(Error::HttpResponse { status, message }) - } - Ok(resp) => { - let hex_str = resp.as_str().map_err(Error::Minreq)?; - let hex_vec = Vec::from_hex(hex_str)?; - deserialize::(&hex_vec).map_err(Error::BitcoinEncoding) - } + let response = self.get_with_retry(path)?; + + if !is_success(&response) { + let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?; + let message = response.as_str().unwrap_or_default().to_string(); + return Err(Error::HttpResponse { status, message }); + } + + let hex_str = response.as_str()?; + deserialize(&Vec::from_hex(hex_str)?).map_err(Error::BitcoinEncoding) + } + + /// Make an HTTP GET request to given URL, deserializing to `Option`. + /// + /// It uses [`BlockingClient::get_response_hex`] internally. + /// + /// See [`BlockingClient::get_response_hex`] above for full + /// documentation. + fn get_opt_response_hex(&self, path: &str) -> Result, Error> { + match self.get_response_hex(path) { + Ok(res) => Ok(Some(res)), + Err(Error::HttpResponse { status: 404, .. }) => Ok(None), Err(e) => Err(e), } } + /// Make an HTTP GET request to given URL, deserializing to any `T` that + /// implements [`serde::de::DeserializeOwned`]. + /// + /// It should be used when requesting Esplora endpoints that have a specific + /// defined API, mostly defined in [`crate::api`]. + /// + /// # Errors + /// + /// This function will return an error either from the HTTP client, or the + /// [`serde::de::DeserializeOwned`] deserialization. fn get_response_json<'a, T: serde::de::DeserializeOwned>( &'a self, path: &'a str, ) -> Result { - let response = self.get_with_retry(path); - match response { - Ok(resp) if !is_status_ok(resp.status_code) => { - let status = u16::try_from(resp.status_code).map_err(Error::StatusCode)?; - let message = resp.as_str().unwrap_or_default().to_string(); - Err(Error::HttpResponse { status, message }) - } - Ok(resp) => Ok(resp.json::().map_err(Error::Minreq)?), - Err(e) => Err(e), + let response = self.get_with_retry(path)?; + + if !is_success(&response) { + let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?; + let message = response.as_str().unwrap_or_default().to_string(); + return Err(Error::HttpResponse { status, message }); } + + response.json::().map_err(Error::BitReq) } + /// Make an HTTP GET request to given URL, deserializing to `Option`. + /// + /// It uses [`BlockingClient::get_response_json`] internally. + /// + /// See [`BlockingClient::get_response_json`] above for full + /// documentation. fn get_opt_response_json( &self, path: &str, ) -> Result, Error> { - match self.get_with_retry(path) { - Ok(resp) if is_status_not_found(resp.status_code) => Ok(None), - Ok(resp) if !is_status_ok(resp.status_code) => { - let status = u16::try_from(resp.status_code).map_err(Error::StatusCode)?; - let message = resp.as_str().unwrap_or_default().to_string(); - Err(Error::HttpResponse { status, message }) - } - Ok(resp) => Ok(Some(resp.json::()?)), + match self.get_response_json(path) { + Ok(res) => Ok(Some(res)), + Err(Error::HttpResponse { status: 404, .. }) => Ok(None), Err(e) => Err(e), } } - fn get_response_str(&self, path: &str) -> Result { - match self.get_with_retry(path) { - Ok(resp) if !is_status_ok(resp.status_code) => { - let status = u16::try_from(resp.status_code).map_err(Error::StatusCode)?; - let message = resp.as_str().unwrap_or_default().to_string(); - Err(Error::HttpResponse { status, message }) - } - Ok(resp) => Ok(resp.as_str()?.to_string()), + /// Make an HTTP GET request to given URL, deserializing to `String`. + /// + /// It should be used when requesting Esplora endpoints that can return + /// `String` formatted data that can be parsed downstream. + /// + /// # Errors + /// + /// This function will return an error either from the HTTP client. + fn get_response_text(&self, path: &str) -> Result { + let response = self.get_with_retry(path)?; + + if !is_success(&response) { + let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?; + let message = response.as_str().unwrap_or_default().to_string(); + return Err(Error::HttpResponse { status, message }); + } + + Ok(response.as_str()?.to_string()) + } + + /// Make an HTTP GET request to given URL, deserializing to `Option`. + /// + /// It uses [`BlockingClient::get_response_text`] internally. + /// + /// See [`BlockingClient::get_response_text`] above for full + /// documentation. + fn get_opt_response_text(&self, path: &str) -> Result, Error> { + match self.get_response_text(path) { + Ok(s) => Ok(Some(s)), + Err(Error::HttpResponse { status: 404, .. }) => Ok(None), Err(e) => Err(e), } } @@ -225,7 +294,10 @@ impl BlockingClient { block_hash: &BlockHash, index: usize, ) -> Result, Error> { - self.get_opt_response_txid(&format!("/block/{block_hash}/txid/{index}")) + match self.get_opt_response_text(&format!("/block/{block_hash}/txid/{index}"))? { + Some(s) => Ok(Some(Txid::from_str(&s).map_err(Error::HexToArray)?)), + None => Ok(None), + } } /// Get the status of a [`Transaction`] given its [`Txid`]. @@ -282,26 +354,12 @@ impl BlockingClient { /// Broadcast a [`Transaction`] to Esplora pub fn broadcast(&self, transaction: &Transaction) -> Result { - let request = self.post_request( - "/tx", - serialize(transaction) - .to_lower_hex_string() - .as_bytes() - .to_vec(), - )?; + let body = serialize::(transaction).to_lower_hex_string(); - match request.send() { - Ok(resp) if !is_status_ok(resp.status_code) => { - let status = u16::try_from(resp.status_code).map_err(Error::StatusCode)?; - let message = resp.as_str().unwrap_or_default().to_string(); - Err(Error::HttpResponse { status, message }) - } - Ok(resp) => { - let txid = Txid::from_str(resp.as_str()?).map_err(Error::HexToArray)?; - Ok(txid) - } - Err(e) => Err(Error::Minreq(e)), - } + let response = self.post_request("/tx", body, None)?; + let txid = Txid::from_str(response.as_str()?).map_err(Error::HexToArray)?; + + Ok(txid) } /// Broadcast a package of [`Transaction`]s to Esplora. @@ -323,47 +381,38 @@ impl BlockingClient { .map(|tx| serialize_hex(&tx)) .collect::>(); - let mut request = self.post_request( - "/txs/package", - serde_json::to_string(&serialized_txs) - .map_err(Error::SerdeJson)? - .into_bytes(), - )?; - + let mut queryparams = HashSet::<(&str, String)>::new(); if let Some(maxfeerate) = maxfeerate { - request = request.with_param("maxfeerate", maxfeerate.to_string()) + queryparams.insert(("maxfeerate", maxfeerate.to_string())); } - if let Some(maxburnamount) = maxburnamount { - request = request.with_param("maxburnamount", maxburnamount.to_string()) + queryparams.insert(("maxburnamount", maxburnamount.to_string())); } - match request.send() { - Ok(resp) if !is_status_ok(resp.status_code) => { - let status = u16::try_from(resp.status_code).map_err(Error::StatusCode)?; - let message = resp.as_str().unwrap_or_default().to_string(); - Err(Error::HttpResponse { status, message }) - } - Ok(resp) => Ok(resp.json::().map_err(Error::Minreq)?), - Err(e) => Err(Error::Minreq(e)), - } + let response = self.post_request( + "/txs/package", + serde_json::to_string(&serialized_txs).map_err(Error::SerdeJson)?, + Some(queryparams), + )?; + + Ok(response.json::()?) } /// Get the height of the current blockchain tip. pub fn get_height(&self) -> Result { - self.get_response_str("/blocks/tip/height") + self.get_response_text("/blocks/tip/height") .map(|s| u32::from_str(s.as_str()).map_err(Error::Parsing))? } /// Get the [`BlockHash`] of the current blockchain tip. pub fn get_tip_hash(&self) -> Result { - self.get_response_str("/blocks/tip/hash") + self.get_response_text("/blocks/tip/hash") .map(|s| BlockHash::from_str(s.as_str()).map_err(Error::HexToArray))? } /// Get the [`BlockHash`] of a specific block height pub fn get_block_hash(&self, block_height: u32) -> Result { - self.get_response_str(&format!("/block-height/{block_height}")) + self.get_response_text(&format!("/block-height/{block_height}")) .map(|s| BlockHash::from_str(s.as_str()).map_err(Error::HexToArray))? } @@ -518,35 +567,4 @@ impl BlockingClient { self.get_response_json(&path) } - - /// Sends a GET request to the given `url`, retrying failed attempts - /// for retryable error codes until max retries hit. - fn get_with_retry(&self, url: &str) -> Result { - let mut delay = BASE_BACKOFF_MILLIS; - let mut attempts = 0; - - loop { - match self.get_request(url)?.send()? { - resp if attempts < self.max_retries && is_status_retryable(resp.status_code) => { - thread::sleep(delay); - attempts += 1; - delay *= 2; - } - resp => return Ok(resp), - } - } - } -} - -fn is_status_ok(status: i32) -> bool { - status == 200 -} - -fn is_status_not_found(status: i32) -> bool { - status == 404 -} - -fn is_status_retryable(status: i32) -> bool { - let status = status as u16; - RETRYABLE_ERROR_CODES.contains(&status) } diff --git a/src/lib.rs b/src/lib.rs index 3ffd4f32..1608493e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,7 +6,7 @@ //! async Esplora client to query Esplora's backend. //! //! The library provides the possibility to build a blocking -//! client using [`minreq`] and an async client using [`reqwest`]. +//! or async client, both using [`bitreq`]. //! The library supports communicating to Esplora via a proxy //! and also using TLS (SSL) for secure communication. //! @@ -46,29 +46,28 @@ //! `esplora-client = { version = "*", default-features = false, features = //! ["blocking"] }` //! -//! * `blocking` enables [`minreq`], the blocking client with proxy. -//! * `blocking-https` enables [`minreq`], the blocking client with proxy and TLS (SSL) capabilities -//! using the default [`minreq`] backend. -//! * `blocking-https-rustls` enables [`minreq`], the blocking client with proxy and TLS (SSL) +//! * `blocking` enables [`bitreq`], the blocking client with proxy. +//! * `blocking-https` enables [`bitreq`], the blocking client with proxy and TLS (SSL) capabilities +//! using the default [`bitreq`] backend. +//! * `blocking-https-rustls` enables [`bitreq`], the blocking client with proxy and TLS (SSL) //! capabilities using the `rustls` backend. -//! * `blocking-https-native` enables [`minreq`], the blocking client with proxy and TLS (SSL) +//! * `blocking-https-native` enables [`bitreq`], the blocking client with proxy and TLS (SSL) //! capabilities using the platform's native TLS backend (likely OpenSSL). -//! * `blocking-https-bundled` enables [`minreq`], the blocking client with proxy and TLS (SSL) +//! * `blocking-https-bundled` enables [`bitreq`], the blocking client with proxy and TLS (SSL) //! capabilities using a bundled OpenSSL library backend. -//! * `async` enables [`reqwest`], the async client with proxy capabilities. -//! * `async-https` enables [`reqwest`], the async client with support for proxying and TLS (SSL) -//! using the default [`reqwest`] TLS backend. -//! * `async-https-native` enables [`reqwest`], the async client with support for proxying and TLS +//! * `async` enables [`bitreq`], the async client with proxy capabilities. +//! * `async-https` enables [`bitreq`], the async client with support for proxying and TLS (SSL) +//! using the default [`bitreq`] TLS backend. +//! * `async-https-native` enables [`bitreq`], the async client with support for proxying and TLS //! (SSL) using the platform's native TLS backend (likely OpenSSL). -//! * `async-https-rustls` enables [`reqwest`], the async client with support for proxying and TLS +//! * `async-https-rustls` enables [`bitreq`], the async client with support for proxying and TLS //! (SSL) using the `rustls` TLS backend. -//! * `async-https-rustls-manual-roots` enables [`reqwest`], the async client with support for +//! * `async-https-rustls-manual-roots` enables [`bitreq`], the async client with support for //! proxying and TLS (SSL) using the `rustls` TLS backend without using the default root //! certificates. //! //! [`dont remove this line or cargo doc will break`]: https://example.com -#![cfg_attr(not(feature = "minreq"), doc = "[`minreq`]: https://docs.rs/minreq")] -#![cfg_attr(not(feature = "reqwest"), doc = "[`reqwest`]: https://docs.rs/reqwest")] +#![cfg_attr(not(feature = "bitreq"), doc = "[`bitreq`]: https://docs.rs/bitreq")] #![allow(clippy::result_large_err)] #![warn(missing_docs)] @@ -87,6 +86,7 @@ pub mod r#async; pub mod blocking; pub use api::*; +use bitreq::Response; #[cfg(feature = "blocking")] pub use blocking::BlockingClient; #[cfg(feature = "async")] @@ -105,6 +105,44 @@ const BASE_BACKOFF_MILLIS: Duration = Duration::from_millis(256); /// Default max retries. const DEFAULT_MAX_RETRIES: usize = 6; +/// Default max cached connections +#[cfg(feature = "async")] +const DEFAULT_MAX_CONNECTIONS: usize = 10; + +/// Check if [`Response`] status is within 100-199. +#[allow(unused)] +fn is_informational(response: &Response) -> bool { + (100..200).contains(&response.status_code) +} + +/// Check if [`Response`] status is within 200-299. +fn is_success(response: &Response) -> bool { + (200..300).contains(&response.status_code) +} + +/// Check if [`Response`] status is within 300-399. +#[allow(unused)] +fn is_redirection(response: &Response) -> bool { + (300..400).contains(&response.status_code) +} + +/// Check if [`Response`] status is within 400-499. +#[allow(unused)] +fn is_client_error(response: &Response) -> bool { + (400..500).contains(&response.status_code) +} + +/// Check if [`Response`] status is within 500-599. +#[allow(unused)] +fn is_server_error(response: &Response) -> bool { + (500..600).contains(&response.status_code) +} + +/// Check if [`Response`] status is within the retryable ones. +fn is_retryable(response: &Response) -> bool { + RETRYABLE_ERROR_CODES.contains(&(response.status_code as u16)) +} + /// Get a fee value in sats/vbytes from the estimates /// that matches the confirmation target set as parameter. /// @@ -130,7 +168,7 @@ pub struct Builder { /// /// Note that the format of this value and the supported protocols change /// slightly between the blocking version of the client (using `minreq`) - /// and the async version (using `reqwest`). For more details check with + /// and the async version (using `bitreq`). For more details check with /// the documentation of the two crates. Both of them are compiled with /// the `socks` feature enabled. /// @@ -142,6 +180,9 @@ pub struct Builder { pub headers: HashMap, /// Max retries pub max_retries: usize, + /// The maximum number of cached connections. + #[cfg(feature = "async")] + pub max_connections: usize, } impl Builder { @@ -153,6 +194,8 @@ impl Builder { timeout: None, headers: HashMap::new(), max_retries: DEFAULT_MAX_RETRIES, + #[cfg(feature = "async")] + max_connections: DEFAULT_MAX_CONNECTIONS, } } @@ -181,6 +224,13 @@ impl Builder { self } + /// Set the maximum number of cached connections in the client. + #[cfg(feature = "async")] + pub fn max_connections(mut self, count: usize) -> Self { + self.max_connections = count; + self + } + /// Build a blocking client from builder #[cfg(feature = "blocking")] pub fn build_blocking(self) -> BlockingClient { @@ -204,12 +254,9 @@ impl Builder { /// Errors that can happen during a request to `Esplora` servers. #[derive(Debug)] pub enum Error { - /// Error during `minreq` HTTP request - #[cfg(feature = "blocking")] - Minreq(minreq::Error), - /// Error during `reqwest` HTTP request - #[cfg(feature = "async")] - Reqwest(reqwest::Error), + /// Error during `bitreq` HTTP request + #[cfg(any(feature = "blocking", feature = "async"))] + BitReq(bitreq::Error), /// Error during JSON (de)serialization SerdeJson(serde_json::Error), /// HTTP response error @@ -263,10 +310,8 @@ macro_rules! impl_error { } impl std::error::Error for Error {} -#[cfg(feature = "blocking")] -impl_error!(::minreq::Error, Minreq, Error); -#[cfg(feature = "async")] -impl_error!(::reqwest::Error, Reqwest, Error); +#[cfg(any(feature = "blocking", feature = "async"))] +impl_error!(::bitreq::Error, BitReq, Error); impl_error!(serde_json::Error, SerdeJson, Error); impl_error!(std::num::ParseIntError, Parsing, Error); impl_error!(bitcoin::consensus::encode::Error, BitcoinEncoding, Error); @@ -281,14 +326,15 @@ mod test { use { bitcoin::{hashes::Hash, Address, Amount}, core::str::FromStr, - electrsd::{corepc_node, electrum_client::ElectrumApi, ElectrsD}, + electrsd::bitcoind::{self, BitcoinD}, + electrsd::{electrum_client::ElectrumApi, ElectrsD}, std::time::Duration, }; /// Struct that holds regtest `bitcoind` and `electrsd` instances. #[cfg(all(feature = "blocking", feature = "async"))] struct TestEnv { - bitcoind: corepc_node::Node, + bitcoind: BitcoinD, electrsd: ElectrsD, } @@ -296,7 +342,7 @@ mod test { #[cfg(all(feature = "blocking", feature = "async"))] pub struct Config<'a> { /// Configuration params for the [`corepc_node::Node`]. - pub bitcoind: corepc_node::Conf<'a>, + pub bitcoind: bitcoind::Conf<'a>, /// Configuration params for the [`electrsd::ElectrsD`]. pub electrsd: electrsd::Conf<'a>, } @@ -307,7 +353,7 @@ mod test { /// HTTP for [`electrsd::ElectrsD`], exposing an Esplora API endpoint. fn default() -> Self { Self { - bitcoind: corepc_node::Conf::default(), + bitcoind: bitcoind::Conf::default(), electrsd: { let mut config = electrsd::Conf::default(); config.http_enabled = true; @@ -330,11 +376,11 @@ mod test { let bitcoind_exe = std::env::var("BITCOIND_EXE") .ok() - .or_else(|| corepc_node::downloaded_exe_path().ok()) + .or_else(|| bitcoind::downloaded_exe_path().ok()) .expect( "Provide a BITCOIND_EXE environment variable, or specify a `bitcoind` version feature", ); - let bitcoind = corepc_node::Node::with_conf(bitcoind_exe, &config.bitcoind).unwrap(); + let bitcoind = BitcoinD::with_conf(bitcoind_exe, &config.bitcoind).unwrap(); let electrs_exe = std::env::var("ELECTRS_EXE") .ok() @@ -353,8 +399,8 @@ mod test { env } - /// Get the [`bitcoind` RPC client](corepc_node::Client). - fn bitcoind_client(&self) -> &corepc_node::Client { + /// Get the [`bitcoind` RPC client](bitcoind::Client). + fn bitcoind_client(&self) -> &bitcoind::Client { &self.bitcoind.client } @@ -765,6 +811,7 @@ mod test { let tx = blocking_client.get_tx(&txid).unwrap(); let async_res = async_client.broadcast(tx.as_ref().unwrap()).await; + println!("{:?}", async_res); let blocking_res = blocking_client.broadcast(tx.as_ref().unwrap()); assert!(async_res.is_err()); assert!(matches!( @@ -1100,7 +1147,7 @@ mod test { #[cfg(all(feature = "blocking", feature = "async"))] #[tokio::test] async fn test_get_tx_with_http_headers() { - use corepc_node::get_available_port; + use bitcoind::get_available_port; use tokio::io::AsyncReadExt; use tokio::net::TcpListener; @@ -1175,10 +1222,9 @@ mod test { ); }; - // minreq's blocking client sends title-case headers: "Authorization" + // both clients should send the expected headers properly assert_request("User-Agent: blocking", exp_header_key); - // reqwest's async client sends lowercase headers: "authorization" - assert_request("user-agent: async", &exp_header_key.to_lowercase()); + assert_request("User-Agent: async", exp_header_key); // cleanup any remaining spawned tasks let _ = blocking_task.await.expect("blocking task should not panic");