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);