diff --git a/Cargo.toml b/Cargo.toml index 1d90703..c2f01b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,10 +19,11 @@ 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 } +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/api.rs b/src/api.rs index d4dfa1e..1d30bb6 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 62b1689..93e4449 100644 --- a/src/async.rs +++ b/src/async.rs @@ -11,10 +11,11 @@ //! Esplora by way of `reqwest` HTTP client. +use async_std::task; 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,18 +25,25 @@ use bitcoin::{ #[allow(unused_imports)] use log::{debug, error, info, trace}; -use reqwest::{header, Client, StatusCode}; +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 { + /// The URL of the Esplora Server. url: String, + /// The inner [`reqwest::Client`] to make HTTP requests. client: Client, + /// Number of times to retry a request + max_retries: usize, } 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(); @@ -61,34 +69,209 @@ 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`] + /// 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, + } } - /// 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.get_with_retry(&url).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: 404, .. }) => Ok(None), + 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 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.get_with_retry(&url).await?; + + 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`. + /// + /// 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: 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. + 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 hex_str = response.text().await?; + Ok(deserialize(&Vec::from_hex(&hex_str)?)?) + } + + /// 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: 404, .. }) => Ok(None), + 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.get_with_retry(&url).await?; + + 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`. + /// + /// 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: 404, .. }) => Ok(None), + Err(e) => Err(e), + } + } + + /// 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?; + + 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`] + 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`]. @@ -107,167 +290,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 @@ -277,101 +348,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, @@ -384,43 +388,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 @@ -429,21 +408,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. @@ -455,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 22c95fd..e9f51b7 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,10 +28,14 @@ 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 { + /// The URL of the Esplora server. url: String, /// The proxy is ignored when targeting `wasm32`. pub proxy: Option, @@ -38,16 +43,19 @@ 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 { - /// 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, proxy: builder.proxy, timeout: builder.timeout, headers: builder.headers, + max_retries: builder.max_retries, } } @@ -79,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)?; @@ -87,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)?; @@ -102,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)?; @@ -121,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(); @@ -137,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), } } @@ -145,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)?; @@ -153,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), } } @@ -161,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)?; @@ -169,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), } } @@ -338,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 { @@ -347,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 7b7efc3..75b4730 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -69,8 +69,7 @@ use std::collections::HashMap; use std::fmt; use std::num::TryFromIntError; - -use bitcoin::consensus; +use std::time::Duration; pub mod api; @@ -85,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. /// @@ -100,6 +112,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, + /// Max retries + pub max_retries: usize, } impl Builder { @@ -128,6 +143,7 @@ impl Builder { proxy: None, timeout: None, headers: HashMap::new(), + max_retries: DEFAULT_MAX_RETRIES, } } @@ -149,20 +165,27 @@ impl Builder { self } - /// build a blocking client from builder + /// 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 { 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 +208,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 +243,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);