Skip to content

Commit f6f16da

Browse files
feature: Allow to customize reqwest client builder (#73)
* feature: Allow to customize reqwest builder * chore: Change from Fn to FnOnce * Rm not needed HttpBuilderFn * Fix tests --------- Co-authored-by: RoDmitry <gh@rdmtr.com>
1 parent 1816735 commit f6f16da

File tree

9 files changed

+406
-32
lines changed

9 files changed

+406
-32
lines changed

typesense/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ trybuild = "1.0.42"
4848
# native-only dev deps
4949
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
5050
tokio = { workspace = true}
51+
tokio-rustls = "0.26"
52+
rcgen = "0.14"
5153
wiremock = "0.6"
5254

5355
# wasm test deps

typesense/src/client/mod.rs

Lines changed: 158 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
//! .api_key("xyz")
2929
//! .healthcheck_interval(Duration::from_secs(60))
3030
//! .retry_policy(ExponentialBackoff::builder().build_with_max_retries(3))
31-
//! .connection_timeout(Duration::from_secs(5))
3231
//! .build()
3332
//! .unwrap();
3433
//!
@@ -59,8 +58,7 @@
5958
//! ### WebAssembly (Wasm) Usage
6059
//!
6160
//! When compiling for a WebAssembly target (`wasm32-unknown-unknown`),
62-
//! Tokio-based features such as middleware, retries, and connection
63-
//! timeouts are **not available**.
61+
//! Tokio-based features such as middleware and retries are **not available**.
6462
//!
6563
//! Example:
6664
//!
@@ -78,8 +76,7 @@
7876
//! .nodes(vec!["http://localhost:8108"])
7977
//! .api_key("xyz")
8078
//! .healthcheck_interval(Duration::from_secs(60))
81-
//! // .retry_policy(...) <-- not supported in Wasm
82-
//! // .connection_timeout(...) <-- not supported in Wasm
79+
//! // .retry_policy(...) <-- not supported in Wasm
8380
//! .build()
8481
//! .unwrap();
8582
//!
@@ -182,6 +179,136 @@ macro_rules! execute_wrapper {
182179
};
183180
}
184181

182+
/// Configuration for a single Typesense node.
183+
///
184+
/// Use this to customize the HTTP client for specific nodes,
185+
/// for example to add custom TLS root certificates or configure proxies.
186+
///
187+
/// For simple cases, you can pass a plain URL string to the builder's
188+
/// `.nodes()` method, which will be automatically converted.
189+
///
190+
/// # Examples
191+
///
192+
/// ```
193+
/// use typesense::NodeConfig;
194+
///
195+
/// // Simple URL (same as passing a string directly)
196+
/// let node = NodeConfig::new("https://node1.example.com");
197+
///
198+
/// // With custom HTTP client configuration
199+
/// // (add timeouts, headers, TLS, etc. on native targets)
200+
/// let node = NodeConfig::new("https://node2.example.com")
201+
/// .http_builder(|builder| {
202+
/// // This closure receives a `reqwest::ClientBuilder` and must return it.
203+
/// // You can call any supported builder methods here; for example,
204+
/// // `builder.connect_timeout(...)` on native targets.
205+
/// builder
206+
/// });
207+
/// ```
208+
pub struct NodeConfig {
209+
url: String,
210+
http_builder: Option<Box<dyn FnOnce(reqwest::ClientBuilder) -> reqwest::ClientBuilder>>,
211+
}
212+
213+
impl std::fmt::Debug for NodeConfig {
214+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215+
f.debug_struct("NodeConfig")
216+
.field("url", &self.url)
217+
.field("http_builder", &self.http_builder.as_ref().map(|_| ".."))
218+
.finish()
219+
}
220+
}
221+
222+
impl NodeConfig {
223+
/// Creates a new `NodeConfig` with the given URL.
224+
pub fn new(url: impl Into<String>) -> Self {
225+
Self {
226+
url: url.into(),
227+
http_builder: None,
228+
}
229+
}
230+
231+
/// Sets a custom HTTP client builder for this node.
232+
///
233+
/// The closure receives a default [`reqwest::ClientBuilder`] and should return
234+
/// a configured builder. This is useful for adding custom TLS certificates,
235+
/// proxies, or other reqwest settings.
236+
///
237+
/// When not set, a default builder with a 5-second connect timeout is used
238+
/// (native targets only; WASM uses the browser's defaults).
239+
///
240+
/// # Examples
241+
///
242+
/// ```no_run
243+
/// #[cfg(not(target_family = "wasm"))]
244+
/// {
245+
/// use typesense::NodeConfig;
246+
///
247+
/// # fn cert() -> reqwest::Certificate { unimplemented!() }
248+
/// let cert = cert();
249+
/// // You can capture arbitrary configuration here (certs, proxies, etc.)
250+
/// // and apply it to the `reqwest::ClientBuilder` on platforms that support it.
251+
/// let node = NodeConfig::new("https://secure.example.com")
252+
/// .http_builder(move |builder| {
253+
/// builder
254+
/// .add_root_certificate(cert)
255+
/// .connect_timeout(std::time::Duration::from_secs(10))
256+
/// });
257+
/// }
258+
/// ```
259+
///
260+
/// # Multiple nodes with the same configuration
261+
///
262+
/// The closure is `FnOnce`, so it is consumed when the HTTP client for that node
263+
/// is built. To use the same configuration (e.g. the same TLS certificate) for
264+
/// multiple nodes, clone the value once per node when building the configs:
265+
///
266+
/// ```no_run
267+
/// #[cfg(not(target_family = "wasm"))]
268+
/// {
269+
/// use typesense::{Client, NodeConfig};
270+
///
271+
/// # fn cert() -> reqwest::Certificate { unimplemented!() }
272+
/// let cert = cert();
273+
/// let nodes = ["https://node1:8108", "https://node2:8108"]
274+
/// .into_iter()
275+
/// .map(|url| {
276+
/// let cert_for_node = cert.clone();
277+
/// NodeConfig::new(url).http_builder(move |b| {
278+
/// b.add_root_certificate(cert_for_node)
279+
/// })
280+
/// })
281+
/// .collect::<Vec<_>>();
282+
/// let _client = Client::builder().nodes(nodes).api_key("key").build();
283+
/// }
284+
/// ```
285+
pub fn http_builder(
286+
mut self,
287+
f: impl FnOnce(reqwest::ClientBuilder) -> reqwest::ClientBuilder + 'static,
288+
) -> Self {
289+
self.http_builder = Some(Box::new(f));
290+
self
291+
}
292+
}
293+
294+
impl From<String> for NodeConfig {
295+
fn from(url: String) -> Self {
296+
Self::new(url)
297+
}
298+
}
299+
300+
impl<'a> From<&'a str> for NodeConfig {
301+
fn from(url: &'a str) -> Self {
302+
Self::new(url)
303+
}
304+
}
305+
306+
impl From<reqwest::Url> for NodeConfig {
307+
fn from(url: reqwest::Url) -> Self {
308+
Self::new(url)
309+
}
310+
}
311+
185312
// This is an internal detail to track the state of each node.
186313
#[derive(Debug)]
187314
struct Node {
@@ -219,54 +346,64 @@ impl Client {
219346
/// - **nearest_node**: None.
220347
/// - **healthcheck_interval**: 60 seconds.
221348
/// - **retry_policy**: Exponential backoff with a maximum of 3 retries. (disabled on WASM)
222-
/// - **connection_timeout**: 5 seconds. (disabled on WASM)
349+
/// - **http_builder**: An `FnOnce(reqwest::ClientBuilder) -> reqwest::ClientBuilder` closure
350+
/// for per-node HTTP client customization (optional, via [`NodeConfig`]).
351+
///
352+
/// When no custom `http_builder` is configured, a default `reqwest::ClientBuilder` with
353+
/// a 5-second connect timeout is used (native targets only).
223354
#[builder]
224355
pub fn new(
225356
/// The Typesense API key used for authentication.
226357
#[builder(into)]
227358
api_key: String,
228359
/// A list of all nodes in the Typesense cluster.
360+
///
361+
/// Accepts plain URL strings or [`NodeConfig`] instances for per-node
362+
/// HTTP client customization.
229363
#[builder(
230-
with = |iter: impl IntoIterator<Item = impl Into<String>>|
231-
iter.into_iter().map(Into::into).collect::<Vec<String>>()
364+
with = |iter: impl IntoIterator<Item = impl Into<NodeConfig>>|
365+
iter.into_iter().map(Into::into).collect::<Vec<NodeConfig>>()
232366
)]
233-
nodes: Vec<String>,
367+
nodes: Vec<NodeConfig>,
234368
#[builder(into)]
235369
/// An optional, preferred node to try first for every request.
236370
/// This is for your server-side load balancer.
237371
/// Do not add this node to all nodes list, should be a separate one.
238-
nearest_node: Option<String>,
372+
nearest_node: Option<NodeConfig>,
239373
#[builder(default = Duration::from_secs(60))]
240374
/// The duration after which an unhealthy node will be retried for requests.
241375
healthcheck_interval: Duration,
242376
#[builder(default = ExponentialBackoff::builder().build_with_max_retries(3))]
243377
/// The retry policy for transient network errors on a *single* node.
244378
retry_policy: ExponentialBackoff,
245-
#[builder(default = Duration::from_secs(5))]
246-
/// The timeout for each individual network request.
247-
connection_timeout: Duration,
248379
) -> Result<Self, &'static str> {
249380
let is_nearest_node_set = nearest_node.is_some();
250381

251382
let nodes: Vec<_> = nodes
252383
.into_iter()
253384
.chain(nearest_node)
254-
.map(|mut url| {
385+
.map(|node_config| {
386+
let builder = match node_config.http_builder {
387+
Some(f) => f(reqwest::Client::builder()),
388+
None => {
389+
let b = reqwest::Client::builder();
390+
#[cfg(not(target_arch = "wasm32"))]
391+
let b = b.connect_timeout(Duration::from_secs(5));
392+
b
393+
}
394+
};
395+
255396
#[cfg(target_arch = "wasm32")]
256-
let http_client = reqwest::Client::builder()
257-
.build()
258-
.expect("Failed to build reqwest client");
397+
let http_client = builder.build().expect("Failed to build reqwest client");
259398

260399
#[cfg(not(target_arch = "wasm32"))]
261400
let http_client = ReqwestMiddlewareClientBuilder::new(
262-
reqwest::Client::builder()
263-
.timeout(connection_timeout)
264-
.build()
265-
.expect("Failed to build reqwest client"),
401+
builder.build().expect("Failed to build reqwest client"),
266402
)
267403
.with(RetryTransientMiddleware::new_with_policy(retry_policy))
268404
.build();
269405

406+
let mut url = node_config.url;
270407
if url.len() > 1 && matches!(url.chars().last(), Some('/')) {
271408
url.pop();
272409
}

typesense/src/lib.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@
4848
//! .api_key("xyz")
4949
//! .healthcheck_interval(Duration::from_secs(60))
5050
//! .retry_policy(ExponentialBackoff::builder().build_with_max_retries(3))
51-
//! .connection_timeout(Duration::from_secs(5))
5251
//! .build()?;
5352
//!
5453
//! // Create the collection in Typesense
@@ -68,8 +67,7 @@
6867
//! ### WebAssembly (Wasm)
6968
//!
7069
//! This example is tailored for a WebAssembly target.
71-
//! Key difference: Tokio-dependent features like `.retry_policy()` and `.connection_timeout()`
72-
//! are disabled. You can still set them in the client builder but it will do nothing.
70+
//! Key difference: Tokio-dependent features like `.retry_policy()` are disabled.
7371
//!
7472
//! ```no_run
7573
//! #[cfg(target_family = "wasm")]
@@ -98,8 +96,7 @@
9896
//! .nodes(vec!["http://localhost:8108"])
9997
//! .api_key("xyz")
10098
//! .healthcheck_interval(Duration::from_secs(60))
101-
//! // .retry_policy(...) <-- disabled in Wasm
102-
//! // .connection_timeout(...) <-- disabled in Wasm
99+
//! // .retry_policy(...) <-- disabled in Wasm
103100
//! .build()
104101
//! .unwrap();
105102
//!
@@ -119,7 +116,7 @@ pub mod error;
119116
pub mod models;
120117
pub mod prelude;
121118

122-
pub use client::{Client, ExponentialBackoff};
119+
pub use client::{Client, ExponentialBackoff, NodeConfig};
123120
pub use error::*;
124121

125122
pub use typesense_codegen as legacy;

typesense/tests/client/client_test.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ fn get_client(nodes: Vec<String>, nearest_node: Option<String>) -> Client {
4949
.api_key("test-key")
5050
.healthcheck_interval(Duration::from_secs(60))
5151
.retry_policy(ExponentialBackoff::builder().build_with_max_retries(0))
52-
.connection_timeout(Duration::from_secs(1))
5352
.build()
5453
.expect("Failed to create client")
5554
}
@@ -186,7 +185,6 @@ async fn test_health_check_and_node_recovery() {
186185
.api_key("test-key")
187186
.healthcheck_interval(Duration::from_millis(500)) // Use a very short healthcheck interval for the test
188187
.retry_policy(ExponentialBackoff::builder().build_with_max_retries(0))
189-
.connection_timeout(Duration::from_secs(1))
190188
.build()
191189
.expect("Failed to create client");
192190

typesense/tests/client/conversation_models_test.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,6 @@ fn get_test_client(uri: &str) -> Client {
116116
.api_key("TEST_API_KEY")
117117
.healthcheck_interval(Duration::from_secs(60))
118118
.retry_policy(ExponentialBackoff::builder().build_with_max_retries(0))
119-
.connection_timeout(Duration::from_secs(1))
120119
.build()
121120
.expect("Failed to create client")
122121
}

0 commit comments

Comments
 (0)