From 31dfa4b9f23f9409a2e150712b4bf78855e86ce1 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Wed, 21 Aug 2024 17:00:53 -0300 Subject: [PATCH 1/5] chore(docs): minor improvements on docstrings - apply some standard on `Cargo.toml` deps. - minor docstring improvements, and fix missing docstrings. --- Cargo.toml | 4 ++-- src/api.rs | 4 ++-- src/async.rs | 6 ++++-- src/blocking.rs | 3 ++- src/lib.rs | 17 ++++++++--------- 5 files changed, 18 insertions(+), 16 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1d90703d..8963fdfc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,10 +19,10 @@ path = "src/lib.rs" [dependencies] serde = { version = "1.0", features = ["derive"] } bitcoin = { version = "0.32", features = ["serde", "std"], default-features = false } -hex = { package = "hex-conservative", version = "0.2" } +hex = { version = "0.2", package = "hex-conservative" } log = "^0.4" minreq = { version = "2.11.0", features = ["json-using-serde"], optional = true } -reqwest = { version = "0.11", optional = true, default-features = false, features = ["json"] } +reqwest = { version = "0.11", features = ["json"], default-features = false, optional = true } [dev-dependencies] serde_json = "1.0" diff --git a/src/api.rs b/src/api.rs index d4dfa1e4..1d30bb67 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,6 +1,6 @@ -//! structs from the esplora API +//! Structs from the Esplora API //! -//! see: +//! See: pub use bitcoin::consensus::{deserialize, serialize}; pub use bitcoin::hex::FromHex; diff --git a/src/async.rs b/src/async.rs index 62b16891..031d7e0d 100644 --- a/src/async.rs +++ b/src/async.rs @@ -30,12 +30,14 @@ use crate::{BlockStatus, BlockSummary, Builder, Error, MerkleProof, OutputStatus #[derive(Debug, Clone)] pub struct AsyncClient { + /// The URL of the Esplora Server. url: String, + /// The inner [`reqwest::Client`] to make HTTP requests. client: Client, } impl AsyncClient { - /// build an async client from a builder + /// Build an async client from a builder pub fn from_builder(builder: Builder) -> Result { let mut client_builder = Client::builder(); @@ -64,7 +66,7 @@ impl AsyncClient { Ok(Self::from_client(builder.base_url, client_builder.build()?)) } - /// build an async client from the base url and [`Client`] + /// Build an async client from the base url and [`Client`] pub fn from_client(url: String, client: Client) -> Self { AsyncClient { url, client } } diff --git a/src/blocking.rs b/src/blocking.rs index 22c95fde..be1cf1ed 100644 --- a/src/blocking.rs +++ b/src/blocking.rs @@ -31,6 +31,7 @@ use crate::{BlockStatus, BlockSummary, Builder, Error, MerkleProof, OutputStatus #[derive(Debug, Clone)] pub struct BlockingClient { + /// The URL of the Esplora server. url: String, /// The proxy is ignored when targeting `wasm32`. pub proxy: Option, @@ -41,7 +42,7 @@ pub struct BlockingClient { } impl BlockingClient { - /// build a blocking client from a [`Builder`] + /// Build a blocking client from a [`Builder`] pub fn from_builder(builder: Builder) -> Self { Self { url: builder.base_url, diff --git a/src/lib.rs b/src/lib.rs index 7b7efc37..20722be6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -70,8 +70,6 @@ use std::collections::HashMap; use std::fmt; use std::num::TryFromIntError; -use bitcoin::consensus; - pub mod api; #[cfg(feature = "async")] @@ -100,6 +98,7 @@ pub fn convert_fee_rate(target: usize, estimates: HashMap) -> Option, /// Socket timeout. pub timeout: Option, - /// HTTP headers to set on every request made to Esplora server + /// HTTP headers to set on every request made to Esplora server. pub headers: HashMap, } @@ -149,20 +148,20 @@ impl Builder { self } - /// build a blocking client from builder + /// Build a blocking client from builder #[cfg(feature = "blocking")] pub fn build_blocking(self) -> BlockingClient { BlockingClient::from_builder(self) } - // build an asynchronous client from builder + // Build an asynchronous client from builder #[cfg(feature = "async")] pub fn build_async(self) -> Result { AsyncClient::from_builder(self) } } -/// Errors that can happen during a sync with `Esplora` +/// Errors that can happen during a request to `Esplora` servers. #[derive(Debug)] pub enum Error { /// Error during `minreq` HTTP request @@ -185,9 +184,9 @@ pub enum Error { HexToBytes(bitcoin::hex::HexToBytesError), /// Transaction not found TransactionNotFound(Txid), - /// Header height not found + /// Block Header height not found HeaderHeightNotFound(u32), - /// Header hash not found + /// Block Header hash not found HeaderHashNotFound(BlockHash), /// Invalid HTTP Header name specified InvalidHttpHeaderName(String), @@ -220,7 +219,7 @@ impl_error!(::minreq::Error, Minreq, Error); #[cfg(feature = "async")] impl_error!(::reqwest::Error, Reqwest, Error); impl_error!(std::num::ParseIntError, Parsing, Error); -impl_error!(consensus::encode::Error, BitcoinEncoding, Error); +impl_error!(bitcoin::consensus::encode::Error, BitcoinEncoding, Error); impl_error!(bitcoin::hex::HexToArrayError, HexToArray, Error); impl_error!(bitcoin::hex::HexToBytesError, HexToBytes, Error); From 565d79e4a6b6d6c0388256d71507403d42a4b6f3 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Wed, 21 Aug 2024 18:45:01 -0300 Subject: [PATCH 2/5] refactor(async): add common GET and POST methods - remove duplicated HTTP client code for handling GET and POST requests. - adds a few new methods to `AsyncClient` implementation, the new methods are responsible to handle common HTTP requests and parsing. It was previously duplicated throughout the Esplora API implementation, but now it follows the same approach already implemented for blocking client (`BlockingClient`). --- src/async.rs | 508 ++++++++++++++++++++++++--------------------------- 1 file changed, 236 insertions(+), 272 deletions(-) diff --git a/src/async.rs b/src/async.rs index 031d7e0d..16d72d5c 100644 --- a/src/async.rs +++ b/src/async.rs @@ -14,7 +14,7 @@ use std::collections::HashMap; use std::str::FromStr; -use bitcoin::consensus::{deserialize, serialize}; +use bitcoin::consensus::{deserialize, serialize, Decodable, Encodable}; use bitcoin::hashes::{sha256, Hash}; use bitcoin::hex::{DisplayHex, FromHex}; use bitcoin::{ @@ -24,7 +24,7 @@ use bitcoin::{ #[allow(unused_imports)] use log::{debug, error, info, trace}; -use reqwest::{header, Client, StatusCode}; +use reqwest::{header, Client}; use crate::{BlockStatus, BlockSummary, Builder, Error, MerkleProof, OutputStatus, Tx, TxStatus}; @@ -71,28 +71,206 @@ impl AsyncClient { AsyncClient { url, client } } - /// Get a [`Transaction`] option given its [`Txid`] - pub async fn get_tx(&self, txid: &Txid) -> Result, Error> { - let resp = self - .client - .get(&format!("{}/tx/{}/raw", self.url, txid)) - .send() - .await?; - - if let StatusCode::NOT_FOUND = resp.status() { - return Ok(None); + /// 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. + async fn get_response(&self, path: &str) -> Result { + let url = format!("{}{}", self.url, path); + let response = self.client.get(url).send().await?; + + if !response.status().is_success() { + return Err(Error::HttpResponse { + status: response.status().as_u16(), + message: response.text().await?, + }); + } + + Ok(deserialize::(&response.bytes().await?)?) + } + + /// Make an HTTP GET request to given URL, deserializing to `Option`. + /// + /// It uses [`AsyncEsploraClient::get_response`] internally. + /// + /// See [`AsyncEsploraClient::get_response`] above for full documentation. + async fn get_opt_response(&self, path: &str) -> Result, Error> { + match self.get_response::(path).await { + Ok(res) => Ok(Some(res)), + Err(Error::HttpResponse { status, message }) => match status { + 404 => Ok(None), + _ => Err(Error::HttpResponse { status, message }), + }, + 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. + async fn get_response_json( + &self, + path: &str, + ) -> Result { + let url = format!("{}{}", self.url, path); + let response = self.client.get(url).send().await?; + + match response.status().is_success() { + true => Ok(response.json::().await.map_err(Error::Reqwest)?), + false => Err(Error::HttpResponse { + status: response.status().as_u16(), + message: response.text().await?, + }), + } + } + + /// Make an HTTP GET request to given URL, deserializing to `Option`. + /// + /// It uses [`AsyncEsploraClient::get_response_json`] internally. + /// + /// See [`AsyncEsploraClient::get_response_json`] above for full + /// documentation. + async fn get_opt_response_json( + &self, + url: &str, + ) -> Result, Error> { + match self.get_response_json(url).await { + Ok(res) => Ok(Some(res)), + Err(Error::HttpResponse { status, message }) => match status { + 404 => Ok(None), + _ => Err(Error::HttpResponse { status, message }), + }, + Err(e) => Err(e), + } + } + + /// Make an HTTP GET request to given URL, deserializing to any `T` that + /// implement [`bitcoin::consensus::Decodable`] from Hex, [`Vec`]. + /// + /// It should be used when requesting Esplora endpoints that can be directly + /// deserialized to native `rust-bitcoin` types, which implements + /// [`bitcoin::consensus::Decodable`] from Hex, `Vec<&u8>`. + /// + /// # Errors + /// + /// 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.client.get(url).send().await?; + + match response.status().is_success() { + true => { + let hex_str = response.text().await?; + let hex_vec = Vec::from_hex(&hex_str)?; + Ok(deserialize(&hex_vec)?) + } + false => Err(Error::HttpResponse { + status: response.status().as_u16(), + message: response.text().await?, + }), + } + } + + /// Make an HTTP GET request to given URL, deserializing to `Option`. + /// + /// It uses [`AsyncEsploraClient::get_response_hex`] internally. + /// + /// See [`AsyncEsploraClient::get_response_hex`] above for full + /// documentation. + async fn get_opt_response_hex(&self, path: &str) -> Result, Error> { + match self.get_response_hex(path).await { + Ok(res) => Ok(Some(res)), + Err(Error::HttpResponse { status, message }) => match status { + 404 => Ok(None), + _ => Err(Error::HttpResponse { status, message }), + }, + Err(e) => Err(e), + } + } + + /// 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. + async fn get_response_text(&self, path: &str) -> Result { + let url = format!("{}{}", self.url, path); + let response = self.client.get(url).send().await?; + + match response.status().is_success() { + true => Ok(response.text().await?), + false => Err(Error::HttpResponse { + status: response.status().as_u16(), + message: response.text().await?, + }), } + } + + /// Make an HTTP GET request to given URL, deserializing to `Option`. + /// + /// It uses [`AsyncEsploraClient::get_response_text`] internally. + /// + /// See [`AsyncEsploraClient::get_response_text`] above for full + /// documentation. + async fn get_opt_response_text(&self, path: &str) -> Result, Error> { + match self.get_response_text(path).await { + Ok(s) => Ok(Some(s)), + Err(Error::HttpResponse { status, message }) => match status { + 404 => Ok(None), + _ => Err(Error::HttpResponse { status, message }), + }, + Err(e) => Err(e), + } + } - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(Some(deserialize(&resp.bytes().await?)?)) + /// Make an HTTP POST request to given URL, serializing from any `T` that + /// implement [`bitcoin::consensus::Encodable`]. + /// + /// It should be used when requesting Esplora endpoints that expected a + /// native bitcoin type serialized with [`bitcoin::consensus::Encodable`]. + /// + /// # Errors + /// + /// This function will return an error either from the HTTP client, or the + /// [`bitcoin::consensus::Encodable`] serialization. + async fn post_request_hex(&self, path: &str, body: T) -> Result<(), Error> { + let url = format!("{}{}", self.url, path); + let body = serialize::(&body).to_lower_hex_string(); + + let response = self.client.post(url).body(body).send().await?; + + match response.status().is_success() { + true => Ok(()), + false => Err(Error::HttpResponse { + status: response.status().as_u16(), + message: response.text().await?, + }), } } + /// Get a [`Transaction`] option given its [`Txid`] + pub async fn get_tx(&self, txid: &Txid) -> Result, Error> { + self.get_opt_response(&format!("/tx/{txid}/raw")).await + } + /// Get a [`Transaction`] given its [`Txid`]. pub async fn get_tx_no_opt(&self, txid: &Txid) -> Result { match self.get_tx(txid).await { @@ -109,167 +287,55 @@ impl AsyncClient { block_hash: &BlockHash, index: usize, ) -> Result, Error> { - let resp = self - .client - .get(&format!("{}/block/{}/txid/{}", self.url, block_hash, index)) - .send() - .await?; - - if let StatusCode::NOT_FOUND = resp.status() { - return Ok(None); - } - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(Some(Txid::from_str(&resp.text().await?)?)) + match self + .get_opt_response_text(&format!("/block/{block_hash}/txid/{index}")) + .await? + { + Some(s) => Ok(Some(Txid::from_str(&s).map_err(Error::HexToArray)?)), + None => Ok(None), } } /// Get the status of a [`Transaction`] given its [`Txid`]. pub async fn get_tx_status(&self, txid: &Txid) -> Result { - let resp = self - .client - .get(&format!("{}/tx/{}/status", self.url, txid)) - .send() - .await?; - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(resp.json().await?) - } + self.get_response_json(&format!("/tx/{txid}/status")).await } /// Get transaction info given it's [`Txid`]. pub async fn get_tx_info(&self, txid: &Txid) -> Result, Error> { - let resp = self - .client - .get(&format!("{}/tx/{}", self.url, txid)) - .send() - .await?; - if resp.status() == StatusCode::NOT_FOUND { - return Ok(None); - } - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(Some(resp.json().await?)) - } + self.get_opt_response_json(&format!("/tx/{txid}")).await } /// Get a [`BlockHeader`] given a particular block hash. pub async fn get_header_by_hash(&self, block_hash: &BlockHash) -> Result { - let resp = self - .client - .get(&format!("{}/block/{}/header", self.url, block_hash)) - .send() - .await?; - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - let header = deserialize(&Vec::from_hex(&resp.text().await?)?)?; - Ok(header) - } + self.get_response_hex(&format!("/block/{block_hash}/header")) + .await } /// Get the [`BlockStatus`] given a particular [`BlockHash`]. pub async fn get_block_status(&self, block_hash: &BlockHash) -> Result { - let resp = self - .client - .get(&format!("{}/block/{}/status", self.url, block_hash)) - .send() - .await?; - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(resp.json().await?) - } + self.get_response_json(&format!("/block/{block_hash}/status")) + .await } /// Get a [`Block`] given a particular [`BlockHash`]. pub async fn get_block_by_hash(&self, block_hash: &BlockHash) -> Result, Error> { - let resp = self - .client - .get(&format!("{}/block/{}/raw", self.url, block_hash)) - .send() - .await?; - - if let StatusCode::NOT_FOUND = resp.status() { - return Ok(None); - } - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(Some(deserialize(&resp.bytes().await?)?)) - } + self.get_opt_response(&format!("/block/{block_hash}/raw")) + .await } /// Get a merkle inclusion proof for a [`Transaction`] with the given /// [`Txid`]. pub async fn get_merkle_proof(&self, tx_hash: &Txid) -> Result, Error> { - let resp = self - .client - .get(&format!("{}/tx/{}/merkle-proof", self.url, tx_hash)) - .send() - .await?; - - if let StatusCode::NOT_FOUND = resp.status() { - return Ok(None); - } - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(Some(resp.json().await?)) - } + self.get_opt_response_json(&format!("/tx/{tx_hash}/merkle-proof")) + .await } /// Get a [`MerkleBlock`] inclusion proof for a [`Transaction`] with the /// given [`Txid`]. pub async fn get_merkle_block(&self, tx_hash: &Txid) -> Result, Error> { - let resp = self - .client - .get(&format!("{}/tx/{}/merkleblock-proof", self.url, tx_hash)) - .send() - .await?; - - if let StatusCode::NOT_FOUND = resp.status() { - return Ok(None); - } - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - let merkle_block = deserialize(&Vec::from_hex(&resp.text().await?)?)?; - Ok(Some(merkle_block)) - } + self.get_opt_response_hex(&format!("/tx/{tx_hash}/merkleblock-proof")) + .await } /// Get the spending status of an output given a [`Txid`] and the output @@ -279,101 +345,34 @@ impl AsyncClient { txid: &Txid, index: u64, ) -> Result, Error> { - let resp = self - .client - .get(&format!("{}/tx/{}/outspend/{}", self.url, txid, index)) - .send() - .await?; - - if let StatusCode::NOT_FOUND = resp.status() { - return Ok(None); - } - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(Some(resp.json().await?)) - } + self.get_opt_response_json(&format!("/tx/{txid}/outspend/{index}")) + .await } /// Broadcast a [`Transaction`] to Esplora pub async fn broadcast(&self, transaction: &Transaction) -> Result<(), Error> { - let resp = self - .client - .post(&format!("{}/tx", self.url)) - .body(serialize(transaction).to_lower_hex_string()) - .send() - .await?; - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(()) - } + self.post_request_hex("/tx", transaction).await } /// Get the current height of the blockchain tip pub async fn get_height(&self) -> Result { - let resp = self - .client - .get(&format!("{}/blocks/tip/height", self.url)) - .send() - .await?; - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(resp.text().await?.parse()?) - } + self.get_response_text("/blocks/tip/height") + .await + .map(|height| u32::from_str(&height).map_err(Error::Parsing))? } /// Get the [`BlockHash`] of the current blockchain tip. pub async fn get_tip_hash(&self) -> Result { - let resp = self - .client - .get(&format!("{}/blocks/tip/hash", self.url)) - .send() - .await?; - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(BlockHash::from_str(&resp.text().await?)?) - } + self.get_response_text("/blocks/tip/hash") + .await + .map(|block_hash| BlockHash::from_str(&block_hash).map_err(Error::HexToArray))? } /// Get the [`BlockHash`] of a specific block height pub async fn get_block_hash(&self, block_height: u32) -> Result { - let resp = self - .client - .get(&format!("{}/block-height/{}", self.url, block_height)) - .send() - .await?; - - if let StatusCode::NOT_FOUND = resp.status() { - return Err(Error::HeaderHeightNotFound(block_height)); - } - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(BlockHash::from_str(&resp.text().await?)?) - } + self.get_response_text(&format!("/block-height/{block_height}")) + .await + .map(|block_hash| BlockHash::from_str(&block_hash).map_err(Error::HexToArray))? } /// Get confirmed transaction history for the specified address/scripthash, @@ -386,43 +385,18 @@ impl AsyncClient { last_seen: Option, ) -> Result, Error> { let script_hash = sha256::Hash::hash(script.as_bytes()); - let url = match last_seen { - Some(last_seen) => format!( - "{}/scripthash/{:x}/txs/chain/{}", - self.url, script_hash, last_seen - ), - None => format!("{}/scripthash/{:x}/txs", self.url, script_hash), + let path = match last_seen { + Some(last_seen) => format!("/scripthash/{:x}/txs/chain/{}", script_hash, last_seen), + None => format!("/scripthash/{:x}/txs", script_hash), }; - let resp = self.client.get(url).send().await?; - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(resp.json::>().await?) - } + self.get_response_json(&path).await } /// Get an map where the key is the confirmation target (in number of /// blocks) and the value is the estimated feerate (in sat/vB). pub async fn get_fee_estimates(&self) -> Result, Error> { - let resp = self - .client - .get(&format!("{}/fee-estimates", self.url,)) - .send() - .await?; - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(resp.json::>().await?) - } + self.get_response_json("/fee-estimates").await } /// Gets some recent block summaries starting at the tip or at `height` if @@ -431,21 +405,11 @@ impl AsyncClient { /// The maximum number of summaries returned depends on the backend itself: /// esplora returns `10` while [mempool.space](https://mempool.space/docs/api) returns `15`. pub async fn get_blocks(&self, height: Option) -> Result, Error> { - let url = match height { - Some(height) => format!("{}/blocks/{}", self.url, height), - None => format!("{}/blocks", self.url), + let path = match height { + Some(height) => format!("/blocks/{height}"), + None => "/blocks".to_string(), }; - - let resp = self.client.get(&url).send().await?; - - if resp.status().is_server_error() || resp.status().is_client_error() { - Err(Error::HttpResponse { - status: resp.status().as_u16(), - message: resp.text().await?, - }) - } else { - Ok(resp.json::>().await?) - } + self.get_response_json(&path).await } /// Get the underlying base URL. From 442789cca2fde11a51460dd194153ac909a2ffaa Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Tue, 3 Sep 2024 11:43:30 -0300 Subject: [PATCH 3/5] chore(style): update new methods style --- src/async.rs | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/src/async.rs b/src/async.rs index 16d72d5c..8a56f2ac 100644 --- a/src/async.rs +++ b/src/async.rs @@ -129,13 +129,14 @@ impl AsyncClient { let url = format!("{}{}", self.url, path); let response = self.client.get(url).send().await?; - match response.status().is_success() { - true => Ok(response.json::().await.map_err(Error::Reqwest)?), - false => Err(Error::HttpResponse { + if !response.status().is_success() { + return Err(Error::HttpResponse { status: response.status().as_u16(), message: response.text().await?, - }), + }); } + + response.json::().await.map_err(Error::Reqwest) } /// Make an HTTP GET request to given URL, deserializing to `Option`. @@ -173,17 +174,15 @@ impl AsyncClient { let url = format!("{}{}", self.url, path); let response = self.client.get(url).send().await?; - match response.status().is_success() { - true => { - let hex_str = response.text().await?; - let hex_vec = Vec::from_hex(&hex_str)?; - Ok(deserialize(&hex_vec)?) - } - false => Err(Error::HttpResponse { + if !response.status().is_success() { + return Err(Error::HttpResponse { status: response.status().as_u16(), message: response.text().await?, - }), + }); } + + let hex_str = response.text().await?; + Ok(deserialize(&Vec::from_hex(&hex_str)?)?) } /// Make an HTTP GET request to given URL, deserializing to `Option`. @@ -215,13 +214,14 @@ impl AsyncClient { let url = format!("{}{}", self.url, path); let response = self.client.get(url).send().await?; - match response.status().is_success() { - true => Ok(response.text().await?), - false => Err(Error::HttpResponse { + if !response.status().is_success() { + return Err(Error::HttpResponse { status: response.status().as_u16(), message: response.text().await?, - }), + }); } + + Ok(response.text().await?) } /// Make an HTTP GET request to given URL, deserializing to `Option`. @@ -257,13 +257,14 @@ impl AsyncClient { let response = self.client.post(url).body(body).send().await?; - match response.status().is_success() { - true => Ok(()), - false => Err(Error::HttpResponse { + if !response.status().is_success() { + return Err(Error::HttpResponse { status: response.status().as_u16(), message: response.text().await?, - }), + }); } + + Ok(()) } /// Get a [`Transaction`] option given its [`Txid`] From 1103936477a2f502658b6240ea9be8917e8f12b0 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Mon, 9 Sep 2024 15:01:31 -0300 Subject: [PATCH 4/5] chore(style+docs): style and docs retouch --- src/async.rs | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/src/async.rs b/src/async.rs index 8a56f2ac..ac0a761a 100644 --- a/src/async.rs +++ b/src/async.rs @@ -104,10 +104,7 @@ impl AsyncClient { async fn get_opt_response(&self, path: &str) -> Result, Error> { match self.get_response::(path).await { Ok(res) => Ok(Some(res)), - Err(Error::HttpResponse { status, message }) => match status { - 404 => Ok(None), - _ => Err(Error::HttpResponse { status, message }), - }, + Err(Error::HttpResponse { status: 404, .. }) => Ok(None), Err(e) => Err(e), } } @@ -151,20 +148,17 @@ impl AsyncClient { ) -> Result, Error> { match self.get_response_json(url).await { Ok(res) => Ok(Some(res)), - Err(Error::HttpResponse { status, message }) => match status { - 404 => Ok(None), - _ => Err(Error::HttpResponse { status, message }), - }, + Err(Error::HttpResponse { status: 404, .. }) => Ok(None), Err(e) => Err(e), } } /// Make an HTTP GET request to given URL, deserializing to any `T` that - /// implement [`bitcoin::consensus::Decodable`] from Hex, [`Vec`]. + /// implements [`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 Hex, `Vec<&u8>`. + /// 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 /// @@ -194,10 +188,7 @@ impl AsyncClient { async fn get_opt_response_hex(&self, path: &str) -> Result, Error> { match self.get_response_hex(path).await { Ok(res) => Ok(Some(res)), - Err(Error::HttpResponse { status, message }) => match status { - 404 => Ok(None), - _ => Err(Error::HttpResponse { status, message }), - }, + Err(Error::HttpResponse { status: 404, .. }) => Ok(None), Err(e) => Err(e), } } @@ -233,10 +224,7 @@ impl AsyncClient { async fn get_opt_response_text(&self, path: &str) -> Result, Error> { match self.get_response_text(path).await { Ok(s) => Ok(Some(s)), - Err(Error::HttpResponse { status, message }) => match status { - 404 => Ok(None), - _ => Err(Error::HttpResponse { status, message }), - }, + Err(Error::HttpResponse { status: 404, .. }) => Ok(None), Err(e) => Err(e), } } From b96270e2993377f121fccdbf117676830f059049 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Fri, 6 Sep 2024 20:24:13 -0400 Subject: [PATCH 5/5] feat(async,blocking)!: implement retryable calls This change also adds a new field `max_retries` to each of `Builder`, `AsyncClient`, and `BlockingClient` that defines the maximum number of times we may retry a request. --- Cargo.toml | 3 ++- src/async.rs | 52 ++++++++++++++++++++++++++++++++++------- src/blocking.rs | 62 ++++++++++++++++++++++++++++++++++++------------- src/lib.rs | 24 +++++++++++++++++++ 4 files changed, 116 insertions(+), 25 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8963fdfc..c2f01b4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ hex = { version = "0.2", package = "hex-conservative" } log = "^0.4" minreq = { version = "2.11.0", features = ["json-using-serde"], optional = true } reqwest = { version = "0.11", features = ["json"], default-features = false, optional = true } +async-std = { version = "1.13.0", optional = true } [dev-dependencies] serde_json = "1.0" @@ -37,7 +38,7 @@ 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"] -async = ["reqwest", "reqwest/socks"] +async = ["async-std", "reqwest", "reqwest/socks"] async-https = ["async", "reqwest/default-tls"] async-https-native = ["async", "reqwest/native-tls"] async-https-rustls = ["async", "reqwest/rustls-tls"] diff --git a/src/async.rs b/src/async.rs index ac0a761a..93e44491 100644 --- a/src/async.rs +++ b/src/async.rs @@ -11,6 +11,7 @@ //! Esplora by way of `reqwest` HTTP client. +use async_std::task; use std::collections::HashMap; use std::str::FromStr; @@ -24,9 +25,12 @@ use bitcoin::{ #[allow(unused_imports)] use log::{debug, error, info, trace}; -use reqwest::{header, Client}; +use reqwest::{header, Client, Response}; -use crate::{BlockStatus, BlockSummary, Builder, Error, MerkleProof, OutputStatus, Tx, TxStatus}; +use crate::{ + BlockStatus, BlockSummary, Builder, Error, MerkleProof, OutputStatus, Tx, TxStatus, + BASE_BACKOFF_MILLIS, RETRYABLE_ERROR_CODES, +}; #[derive(Debug, Clone)] pub struct AsyncClient { @@ -34,6 +38,8 @@ pub struct AsyncClient { url: String, /// The inner [`reqwest::Client`] to make HTTP requests. client: Client, + /// Number of times to retry a request + max_retries: usize, } impl AsyncClient { @@ -63,12 +69,20 @@ impl AsyncClient { client_builder = client_builder.default_headers(headers); } - Ok(Self::from_client(builder.base_url, client_builder.build()?)) + Ok(AsyncClient { + url: builder.base_url, + client: client_builder.build()?, + max_retries: builder.max_retries, + }) } /// Build an async client from the base url and [`Client`] pub fn from_client(url: String, client: Client) -> Self { - AsyncClient { url, client } + AsyncClient { + url, + client, + max_retries: crate::DEFAULT_MAX_RETRIES, + } } /// Make an HTTP GET request to given URL, deserializing to any `T` that @@ -84,7 +98,7 @@ impl AsyncClient { /// [`bitcoin::consensus::Decodable`] deserialization. async fn get_response(&self, path: &str) -> Result { let url = format!("{}{}", self.url, path); - let response = self.client.get(url).send().await?; + let response = self.get_with_retry(&url).await?; if !response.status().is_success() { return Err(Error::HttpResponse { @@ -124,7 +138,7 @@ impl AsyncClient { path: &str, ) -> Result { let url = format!("{}{}", self.url, path); - let response = self.client.get(url).send().await?; + let response = self.get_with_retry(&url).await?; if !response.status().is_success() { return Err(Error::HttpResponse { @@ -166,7 +180,7 @@ impl AsyncClient { /// [`bitcoin::consensus::Decodable`] deserialization. async fn get_response_hex(&self, path: &str) -> Result { let url = format!("{}{}", self.url, path); - let response = self.client.get(url).send().await?; + let response = self.get_with_retry(&url).await?; if !response.status().is_success() { return Err(Error::HttpResponse { @@ -203,7 +217,7 @@ 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.client.get(url).send().await?; + let response = self.get_with_retry(&url).await?; if !response.status().is_success() { return Err(Error::HttpResponse { @@ -410,4 +424,26 @@ impl AsyncClient { 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()) => { + task::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()) } diff --git a/src/blocking.rs b/src/blocking.rs index be1cf1ed..e9f51b75 100644 --- a/src/blocking.rs +++ b/src/blocking.rs @@ -14,11 +14,12 @@ use std::collections::HashMap; use std::convert::TryFrom; use std::str::FromStr; +use std::thread; #[allow(unused_imports)] use log::{debug, error, info, trace}; -use minreq::{Proxy, Request}; +use minreq::{Proxy, Request, Response}; use bitcoin::consensus::{deserialize, serialize, Decodable}; use bitcoin::hashes::{sha256, Hash}; @@ -27,7 +28,10 @@ use bitcoin::{ block::Header as BlockHeader, Block, BlockHash, MerkleBlock, Script, Transaction, Txid, }; -use crate::{BlockStatus, BlockSummary, Builder, Error, MerkleProof, OutputStatus, Tx, TxStatus}; +use crate::{ + BlockStatus, BlockSummary, Builder, Error, MerkleProof, OutputStatus, Tx, TxStatus, + BASE_BACKOFF_MILLIS, RETRYABLE_ERROR_CODES, +}; #[derive(Debug, Clone)] pub struct BlockingClient { @@ -39,6 +43,8 @@ pub struct BlockingClient { pub timeout: Option, /// HTTP headers to set on every request made to Esplora server pub headers: HashMap, + /// Number of times to retry a request + pub max_retries: usize, } impl BlockingClient { @@ -49,6 +55,7 @@ impl BlockingClient { proxy: builder.proxy, timeout: builder.timeout, headers: builder.headers, + max_retries: builder.max_retries, } } @@ -80,7 +87,7 @@ impl BlockingClient { } fn get_opt_response(&self, path: &str) -> Result, Error> { - match self.get_request(path)?.send() { + 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)?; @@ -88,12 +95,12 @@ impl BlockingClient { Err(Error::HttpResponse { status, message }) } Ok(resp) => Ok(Some(deserialize::(resp.as_bytes())?)), - Err(e) => Err(Error::Minreq(e)), + Err(e) => Err(e), } } fn get_opt_response_txid(&self, path: &str) -> Result, Error> { - match self.get_request(path)?.send() { + 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)?; @@ -103,12 +110,12 @@ impl BlockingClient { Ok(resp) => Ok(Some( Txid::from_str(resp.as_str().map_err(Error::Minreq)?).map_err(Error::HexToArray)?, )), - Err(e) => Err(Error::Minreq(e)), + Err(e) => Err(e), } } fn get_opt_response_hex(&self, path: &str) -> Result, Error> { - match self.get_request(path)?.send() { + 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)?; @@ -122,12 +129,12 @@ impl BlockingClient { .map_err(Error::BitcoinEncoding) .map(|r| Some(r)) } - Err(e) => Err(Error::Minreq(e)), + Err(e) => Err(e), } } fn get_response_hex(&self, path: &str) -> Result { - match self.get_request(path)?.send() { + 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(); @@ -138,7 +145,7 @@ impl BlockingClient { let hex_vec = Vec::from_hex(hex_str).unwrap(); deserialize::(&hex_vec).map_err(Error::BitcoinEncoding) } - Err(e) => Err(Error::Minreq(e)), + Err(e) => Err(e), } } @@ -146,7 +153,7 @@ impl BlockingClient { &'a self, path: &'a str, ) -> Result { - let response = self.get_request(path)?.send(); + 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)?; @@ -154,7 +161,7 @@ impl BlockingClient { Err(Error::HttpResponse { status, message }) } Ok(resp) => Ok(resp.json::().map_err(Error::Minreq)?), - Err(e) => Err(Error::Minreq(e)), + Err(e) => Err(e), } } @@ -162,7 +169,7 @@ impl BlockingClient { &self, path: &str, ) -> Result, Error> { - match self.get_request(path)?.send() { + 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)?; @@ -170,19 +177,19 @@ impl BlockingClient { Err(Error::HttpResponse { status, message }) } Ok(resp) => Ok(Some(resp.json::()?)), - Err(e) => Err(Error::Minreq(e)), + Err(e) => Err(e), } } fn get_response_str(&self, path: &str) -> Result { - match self.get_request(path)?.send() { + 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()), - Err(e) => Err(Error::Minreq(e)), + Err(e) => Err(e), } } @@ -339,6 +346,24 @@ 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. + pub 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 { @@ -348,3 +373,8 @@ fn is_status_ok(status: i32) -> bool { 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 20722be6..75b4730a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -69,6 +69,7 @@ use std::collections::HashMap; use std::fmt; use std::num::TryFromIntError; +use std::time::Duration; pub mod api; @@ -83,6 +84,19 @@ pub use blocking::BlockingClient; #[cfg(feature = "async")] pub use r#async::AsyncClient; +/// Response status codes for which the request may be retried. +const RETRYABLE_ERROR_CODES: [u16; 3] = [ + 429, // TOO_MANY_REQUESTS + 500, // INTERNAL_SERVER_ERROR + 503, // SERVICE_UNAVAILABLE +]; + +/// Base backoff in milliseconds. +const BASE_BACKOFF_MILLIS: Duration = Duration::from_millis(256); + +/// Default max retries. +const DEFAULT_MAX_RETRIES: usize = 6; + /// Get a fee value in sats/vbytes from the estimates /// that matches the confirmation target set as parameter. /// @@ -117,6 +131,8 @@ pub struct Builder { pub timeout: Option, /// HTTP headers to set on every request made to Esplora server. pub headers: HashMap, + /// Max retries + pub max_retries: usize, } impl Builder { @@ -127,6 +143,7 @@ impl Builder { proxy: None, timeout: None, headers: HashMap::new(), + max_retries: DEFAULT_MAX_RETRIES, } } @@ -148,6 +165,13 @@ impl Builder { self } + /// Set the maximum number of times to retry a request if the response status + /// is one of [`RETRYABLE_ERROR_CODES`]. + pub fn max_retries(mut self, count: usize) -> Self { + self.max_retries = count; + self + } + /// Build a blocking client from builder #[cfg(feature = "blocking")] pub fn build_blocking(self) -> BlockingClient {