Skip to main content

esplora_client/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! # Esplora Client
4//!
5//! A client library for querying [Esplora] HTTP APIs from Rust.
6//!
7//! The crate exposes a shared set of Esplora response types in [`api`], plus
8//! optional blocking and async clients built on [`bitreq`]. Both clients use the
9//! same [`Builder`] configuration for the base URL, proxy, timeout, custom
10//! headers, and retry policy.
11//!
12//! # Client Modes
13//!
14//! Enable the `blocking` feature to use `BlockingClient`, whose methods block
15//! the current thread until each request completes. Enable the `async` feature
16//! to use `AsyncClient`, whose methods return futures and require an async
17//! runtime. The default async sleeper is backed by Tokio when the `tokio`
18//! feature is enabled; custom runtimes can supply their own `Sleeper`.
19//!
20//! # Examples
21//!
22//! Create a blocking client:
23//!
24//! ```rust,ignore
25//! use esplora_client::Builder;
26//!
27//! fn main() -> Result<(), esplora_client::Error> {
28//!     let builder = Builder::new("https://blockstream.info/testnet/api");
29//!     let blocking_client = builder.build_blocking();
30//!     let height = blocking_client.get_height()?;
31//!
32//!     Ok(())
33//! }
34//! ```
35//!
36//! Create an async client:
37//!
38//! ```rust,ignore
39//! use esplora_client::Builder;
40//!
41//! #[tokio::main]
42//! async fn main() -> Result<(), esplora_client::Error> {
43//! let builder = Builder::new("https://blockstream.info/testnet/api");
44//! let async_client = builder.build_async()?;
45//! let height = async_client.get_height().await?;
46//!
47//! Ok(())
48//! }
49//! ```
50//!
51//! # Retries
52//!
53//! Both clients retry responses with status codes listed in
54//! [`RETRYABLE_ERROR_CODES`]. Retry attempts use exponential backoff starting
55//! at 256 milliseconds and are controlled by [`Builder::max_retries`].
56//!
57//! # Features
58//!
59//! By default the crate enables all features. To select only the pieces you
60//! need, set `default-features = false` in `Cargo.toml` and list the desired
61//! features explicitly:
62//!
63//! ```toml
64//! esplora-client = { version = "*", default-features = false, features = ["blocking"] }
65//! ```
66//!
67//! * `blocking` enables [`bitreq`], the blocking client with proxy.
68//! * `blocking-https` enables [`bitreq`], the blocking client with proxy and TLS (SSL) capabilities
69//!   using the default [`bitreq`] backend.
70//! * `blocking-https-rustls` enables [`bitreq`], the blocking client with proxy and TLS (SSL)
71//!   capabilities using the `rustls` backend.
72//! * `blocking-https-native` enables [`bitreq`], the blocking client with proxy and TLS (SSL)
73//!   capabilities using the platform's native TLS backend (likely OpenSSL).
74//! * `blocking-https-rustls-probe` enables [`bitreq`], the blocking client with proxy and TLS (SSL)
75//!   capabilities using `rustls` and probed system roots.
76//! * `async` enables [`bitreq`], the async client with proxy capabilities.
77//! * `async-https` enables [`bitreq`], the async client with support for proxying and TLS (SSL)
78//!   using the default [`bitreq`] TLS backend.
79//! * `async-https-native` enables [`bitreq`], the async client with support for proxying and TLS
80//!   (SSL) using the platform's native TLS backend (likely OpenSSL).
81//! * `async-https-rustls` enables [`bitreq`], the async client with support for proxying and TLS
82//!   (SSL) using the `rustls` TLS backend.
83//! * `async-https-rustls-probe` enables [`bitreq`], the async client with support for proxying and
84//!   TLS (SSL) using `rustls` and probed system roots.
85//! * `tokio` enables the default async sleeper used by [`Builder::build_async`].
86//!
87//! [Esplora]: https://github.com/Blockstream/esplora/blob/master/API.md
88//! [`bitreq`]: https://docs.rs/bitreq
89#![allow(clippy::result_large_err)]
90#![warn(missing_docs)]
91#![allow(deprecated)]
92
93use std::collections::HashMap;
94use std::fmt;
95use std::num::TryFromIntError;
96use std::time::Duration;
97
98#[cfg(feature = "async")]
99pub use r#async::Sleeper;
100
101pub mod api;
102#[cfg(feature = "async")]
103pub mod r#async;
104#[cfg(feature = "blocking")]
105pub mod blocking;
106
107pub use api::*;
108#[cfg(any(feature = "blocking", feature = "async"))]
109use bitreq::Response;
110#[cfg(feature = "blocking")]
111pub use blocking::BlockingClient;
112#[cfg(feature = "async")]
113pub use r#async::AsyncClient;
114
115/// HTTP response status codes for which a request may be retried.
116pub const RETRYABLE_ERROR_CODES: [u16; 3] = [
117    429, // TOO_MANY_REQUESTS
118    500, // INTERNAL_SERVER_ERROR
119    503, // SERVICE_UNAVAILABLE
120];
121
122/// Base delay used by the exponential retry backoff.
123#[cfg(any(feature = "blocking", feature = "async"))]
124const BASE_BACKOFF_MILLIS: Duration = Duration::from_millis(256);
125
126/// Default maximum number of retry attempts per request.
127const DEFAULT_MAX_RETRIES: usize = 6;
128
129/// Default maximum number of cached connections for the async client.
130#[cfg(feature = "async")]
131const DEFAULT_MAX_CONNECTIONS: usize = 10;
132
133/// Check if the [`Response`] status code is informational (100-199).
134#[allow(unused)]
135#[cfg(any(feature = "blocking", feature = "async"))]
136fn is_informational(response: &Response) -> bool {
137    (100..200).contains(&response.status_code)
138}
139
140/// Check if the [`Response`] status code is successful (200-299).
141#[cfg(any(feature = "blocking", feature = "async"))]
142fn is_success(response: &Response) -> bool {
143    (200..300).contains(&response.status_code)
144}
145
146/// Check if the [`Response`] status code is a redirection (300-399).
147#[allow(unused)]
148#[cfg(any(feature = "blocking", feature = "async"))]
149fn is_redirection(response: &Response) -> bool {
150    (300..400).contains(&response.status_code)
151}
152
153/// Check if the [`Response`] status code is a client error (400-499).
154#[allow(unused)]
155#[cfg(any(feature = "blocking", feature = "async"))]
156fn is_client_error(response: &Response) -> bool {
157    (400..500).contains(&response.status_code)
158}
159
160/// Check if the [`Response`] status code is a server error (500-599).
161#[allow(unused)]
162#[cfg(any(feature = "blocking", feature = "async"))]
163fn is_server_error(response: &Response) -> bool {
164    (500..600).contains(&response.status_code)
165}
166
167/// Check if the [`Response`] status code is retryable (429, 500, 503).
168#[cfg(any(feature = "blocking", feature = "async"))]
169fn is_retryable(response: &Response) -> bool {
170    RETRYABLE_ERROR_CODES.contains(&(response.status_code as u16))
171}
172
173/// Convert a [`Duration`] to whole timeout seconds for `bitreq`.
174#[cfg(any(feature = "blocking", feature = "async"))]
175fn duration_to_timeout_secs(duration: Duration) -> u64 {
176    if duration.subsec_nanos() == 0 {
177        duration.as_secs()
178    } else {
179        duration.as_secs().saturating_add(1)
180    }
181}
182
183/// Return the [`FeeRate`] for the given confirmation target in blocks.
184///
185/// Selects the highest confirmation target from `estimates` that is at or
186/// below `target_blocks`, and returns its [`FeeRate`]. Returns `None` if no
187/// matching estimate is found.
188pub fn convert_fee_rate(target_blocks: usize, estimates: HashMap<u16, FeeRate>) -> Option<FeeRate> {
189    estimates
190        .into_iter()
191        .filter(|(k, _)| *k as usize <= target_blocks)
192        .max_by_key(|(k, _)| *k)
193        .map(|(_, feerate)| feerate)
194}
195
196/// Convert a [`HashMap`] of fee estimates expressed as sat/vB ([`f64`]) into [`FeeRate`]s.
197pub fn sat_per_vbyte_to_feerate(estimates: HashMap<u16, f64>) -> HashMap<u16, FeeRate> {
198    estimates
199        .into_iter()
200        .map(|(k, v)| (k, FeeRate::from_sat_per_kwu((v * 250.0).round() as u64)))
201        .collect()
202}
203
204/// Shared configuration for `BlockingClient` and `AsyncClient`.
205///
206/// Start with [`Builder::new`] and then chain optional configuration methods
207/// before calling `Builder::build_blocking`, `Builder::build_async`, or
208/// `Builder::build_async_with_sleeper`.
209///
210/// # Example
211///
212/// ```no_run
213/// use std::time::Duration;
214///
215/// # #[cfg(feature = "blocking")]
216/// # {
217/// let client = esplora_client::Builder::new("https://mempool.space/testnet/api")
218///     .timeout(Duration::from_secs(30))
219///     .max_retries(4)
220///     .header("user-agent", "my-wallet/0.1")
221///     .build_blocking();
222/// # }
223/// ```
224#[derive(Debug, Clone)]
225pub struct Builder {
226    /// The base URL of the Esplora server.
227    ///
228    /// This should include the API prefix expected by the server, for example
229    /// `https://mempool.space/api` or `https://blockstream.info/testnet/api`.
230    pub base_url: String,
231    /// Optional proxy URL used for requests to the Esplora server.
232    ///
233    /// The string should be formatted as:
234    /// `<protocol>://<user>:<password>@host:<port>`.
235    ///
236    /// Note that the format of this value and the supported protocols change
237    /// slightly by target and enabled transport features. See [bitreq]'s
238    /// proxy documentation for the accepted schemes.
239    ///
240    /// The proxy is ignored when targeting `wasm32`.
241    pub proxy: Option<String>,
242    /// Per-request socket timeout.
243    pub timeout: Option<Duration>,
244    /// HTTP headers to set on every request made to the Esplora server.
245    pub headers: HashMap<String, String>,
246    /// Maximum number of retry attempts for retryable HTTP responses.
247    pub max_retries: usize,
248    /// Maximum number of cached connections for the async client.
249    #[cfg(feature = "async")]
250    pub max_connections: usize,
251}
252
253impl Builder {
254    /// Create a [`Builder`] for an Esplora server base URL.
255    ///
256    /// The URL is stored exactly as provided and request paths are appended to
257    /// it. Do not include a trailing slash unless your server expects one.
258    pub fn new(base_url: &str) -> Self {
259        Builder {
260            base_url: base_url.to_string(),
261            proxy: None,
262            timeout: None,
263            headers: HashMap::new(),
264            max_retries: DEFAULT_MAX_RETRIES,
265            #[cfg(feature = "async")]
266            max_connections: DEFAULT_MAX_CONNECTIONS,
267        }
268    }
269
270    /// Set the proxy URL used for requests.
271    ///
272    /// The proxy is ignored when targeting `wasm32`.
273    pub fn proxy(mut self, proxy: &str) -> Self {
274        self.proxy = Some(proxy.to_string());
275        self
276    }
277
278    /// Set the per-request socket timeout.
279    pub fn timeout(mut self, timeout: Duration) -> Self {
280        self.timeout = Some(timeout);
281        self
282    }
283
284    /// Add or replace an HTTP header sent with every request.
285    pub fn header(mut self, key: &str, value: &str) -> Self {
286        self.headers.insert(key.to_string(), value.to_string());
287        self
288    }
289
290    /// Set the maximum number of retry attempts for retryable responses.
291    ///
292    /// Only responses whose status code is listed in
293    /// [`RETRYABLE_ERROR_CODES`] are retried.
294    pub fn max_retries(mut self, count: usize) -> Self {
295        self.max_retries = count;
296        self
297    }
298
299    /// Set the maximum number of cached connections in the async client.
300    #[cfg(feature = "async")]
301    pub fn max_connections(mut self, count: usize) -> Self {
302        self.max_connections = count;
303        self
304    }
305
306    /// Build a [`BlockingClient`] from this configuration.
307    #[cfg(feature = "blocking")]
308    pub fn build_blocking(self) -> BlockingClient {
309        BlockingClient::from_builder(self)
310    }
311
312    /// Build an [`AsyncClient`] from this configuration.
313    ///
314    /// This uses `DefaultSleeper`, which is backed by Tokio.
315    ///
316    /// # Errors
317    ///
318    /// Returns an [`Error`] if the async HTTP client cannot be constructed.
319    #[cfg(all(feature = "async", feature = "tokio"))]
320    pub fn build_async(self) -> Result<AsyncClient, Error> {
321        AsyncClient::from_builder(self)
322    }
323
324    /// Build an [`AsyncClient`] with a user-defined [`Sleeper`].
325    ///
326    /// Use this when integrating with an async runtime other than Tokio or when
327    /// tests need a custom sleep implementation.
328    ///
329    /// # Errors
330    ///
331    /// Returns an [`Error`] if the async HTTP client cannot be constructed.
332    #[cfg(feature = "async")]
333    pub fn build_async_with_sleeper<S: Sleeper>(self) -> Result<AsyncClient<S>, Error> {
334        AsyncClient::from_builder(self)
335    }
336}
337
338/// Errors that can occur while building clients, sending requests, or decoding responses.
339#[derive(Debug)]
340pub enum Error {
341    /// Error during a [`bitreq`] HTTP request.
342    #[cfg(any(feature = "blocking", feature = "async"))]
343    BitReq(bitreq::Error),
344    /// Error during JSON serialization or deserialization.
345    SerdeJson(serde_json::Error),
346    /// Non-successful HTTP response from the Esplora server.
347    HttpResponse {
348        /// The HTTP status code returned by the server.
349        status: u16,
350        /// The response body returned by the server.
351        message: String,
352    },
353    /// Invalid integer returned by the server.
354    Parsing(std::num::ParseIntError),
355    /// Invalid status code, unable to convert to `u16`.
356    StatusCode(TryFromIntError),
357    /// Invalid Bitcoin consensus data returned by the server.
358    BitcoinEncoding(bitcoin::consensus::encode::Error),
359    /// Invalid fixed-size hex data returned by the server.
360    HexToArray(bitcoin::hex::HexToArrayError),
361    /// Invalid variable-length hex data returned by the server.
362    HexToBytes(bitcoin::hex::HexToBytesError),
363    /// Transaction not found.
364    TransactionNotFound(Txid),
365    /// Block header height not found.
366    HeaderHeightNotFound(u32),
367    /// Block header hash not found.
368    HeaderHashNotFound(BlockHash),
369    /// Invalid HTTP header name specified in [`Builder::header`].
370    InvalidHttpHeaderName(String),
371    /// Invalid HTTP header value specified in [`Builder::header`].
372    InvalidHttpHeaderValue(String),
373    /// The server sent an invalid response.
374    InvalidResponse,
375}
376
377impl fmt::Display for Error {
378    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
379        match self {
380            #[cfg(any(feature = "blocking", feature = "async"))]
381            Error::BitReq(e) => write!(f, "Bitreq HTTP error: {e}"),
382            Error::SerdeJson(e) => write!(f, "JSON (de)serialization error: {e}"),
383            Error::HttpResponse { status, message } => {
384                write!(f, "HTTP error {status}: {message}")
385            }
386            Error::Parsing(e) => write!(f, "Failed to parse invalid number: {e}"),
387            Error::StatusCode(e) => write!(f, "Invalid status code: {e}"),
388            Error::BitcoinEncoding(e) => write!(f, "Invalid Bitcoin data: {e}"),
389            Error::HexToArray(e) => write!(f, "Invalid hex to array conversion: {e}"),
390            Error::HexToBytes(e) => write!(f, "Invalid hex to bytes conversion: {e}"),
391            Error::TransactionNotFound(txid) => {
392                write!(f, "Transaction not found: {txid}")
393            }
394            Error::HeaderHeightNotFound(height) => {
395                write!(f, "Block header at height {height} not found")
396            }
397            Error::HeaderHashNotFound(hash) => {
398                write!(f, "Block header with hash {hash} not found")
399            }
400            Error::InvalidHttpHeaderName(name) => {
401                write!(f, "Invalid HTTP header name: {name}")
402            }
403            Error::InvalidHttpHeaderValue(value) => {
404                write!(f, "Invalid HTTP header value: {value}")
405            }
406            Error::InvalidResponse => write!(f, "The server sent an invalid response"),
407        }
408    }
409}
410
411impl std::error::Error for Error {}
412
413macro_rules! impl_error {
414    ( $from:ty, $to:ident ) => {
415        impl_error!($from, $to, Error);
416    };
417    ( $from:ty, $to:ident, $impl_for:ty ) => {
418        impl std::convert::From<$from> for $impl_for {
419            fn from(err: $from) -> Self {
420                <$impl_for>::$to(err)
421            }
422        }
423    };
424}
425
426#[cfg(any(feature = "blocking", feature = "async"))]
427impl_error!(::bitreq::Error, BitReq, Error);
428impl_error!(serde_json::Error, SerdeJson, Error);
429impl_error!(std::num::ParseIntError, Parsing, Error);
430impl_error!(bitcoin::consensus::encode::Error, BitcoinEncoding, Error);
431impl_error!(bitcoin::hex::HexToArrayError, HexToArray, Error);
432impl_error!(bitcoin::hex::HexToBytesError, HexToBytes, Error);