Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions typesense/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ trybuild = "1.0.42"
# native-only dev deps
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
tokio = { workspace = true}
tokio-rustls = "0.26"
rcgen = "0.14"
wiremock = "0.6"

# wasm test deps
Expand Down
154 changes: 133 additions & 21 deletions typesense/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
//! .api_key("xyz")
//! .healthcheck_interval(Duration::from_secs(60))
//! .retry_policy(ExponentialBackoff::builder().build_with_max_retries(3))
//! .connection_timeout(Duration::from_secs(5))
//! .build()
//! .unwrap();
//!
Expand Down Expand Up @@ -59,8 +58,7 @@
//! ### WebAssembly (Wasm) Usage
//!
//! When compiling for a WebAssembly target (`wasm32-unknown-unknown`),
//! Tokio-based features such as middleware, retries, and connection
//! timeouts are **not available**.
//! Tokio-based features such as middleware and retries are **not available**.
//!
//! Example:
//!
Expand All @@ -78,8 +76,7 @@
//! .nodes(vec!["http://localhost:8108"])
//! .api_key("xyz")
//! .healthcheck_interval(Duration::from_secs(60))
//! // .retry_policy(...) <-- not supported in Wasm
//! // .connection_timeout(...) <-- not supported in Wasm
//! // .retry_policy(...) <-- not supported in Wasm
//! .build()
//! .unwrap();
//!
Expand Down Expand Up @@ -182,6 +179,111 @@
};
}

/// Configuration for a single Typesense node.
///
/// Use this to customize the HTTP client for specific nodes,
/// for example to add custom TLS root certificates or configure proxies.
///
/// For simple cases, you can pass a plain URL string to the builder's
/// `.nodes()` method, which will be automatically converted.
///
/// # Examples
///
/// ```
/// use typesense::NodeConfig;
///
/// // Simple URL (same as passing a string directly)
/// let node = NodeConfig::new("https://node1.example.com");
///
/// // With custom HTTP client configuration
/// // (add timeouts, headers, TLS, etc. on native targets)
/// let node = NodeConfig::new("https://node2.example.com")
/// .http_builder(|builder| {
/// // This closure receives a `reqwest::ClientBuilder` and must return it.
/// // You can call any supported builder methods here; for example,
/// // `builder.connect_timeout(...)` on native targets.
/// builder
/// });
/// ```
pub struct NodeConfig {
url: String,
http_builder: Option<Box<dyn Fn(reqwest::ClientBuilder) -> reqwest::ClientBuilder>>,
}

impl std::fmt::Debug for NodeConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NodeConfig")
.field("url", &self.url)
.field("http_builder", &self.http_builder.as_ref().map(|_| ".."))
.finish()
}
}

impl NodeConfig {
/// Creates a new `NodeConfig` with the given URL.
pub fn new(url: impl Into<String>) -> Self {
Self {
url: url.into(),
http_builder: None,
}
}

/// Sets a custom HTTP client builder for this node.
///
/// The closure receives a default [`reqwest::ClientBuilder`] and should return
/// a configured builder. This is useful for adding custom TLS certificates,
/// proxies, or other reqwest settings.
///
/// When not set, a default builder with a 5-second connect timeout is used
/// (native targets only; WASM uses the browser's defaults).
///
/// # Examples
///
/// ```no_run
/// use typesense::NodeConfig;
///
/// // You can capture arbitrary configuration here (certs, proxies, etc.)
/// // and apply it to the `reqwest::ClientBuilder` on platforms that support it.
/// let node = NodeConfig::new("https://secure.example.com")
/// .http_builder(move |builder| {
/// // Example (native-only, not shown here to keep the example
/// // portable across native and WASM):
/// //
/// // builder
/// // .add_root_certificate(cert.clone())
/// // .connect_timeout(std::time::Duration::from_secs(10))
/// //
/// // For this doctest, we just return the builder unchanged.
/// builder
/// });
/// ```
pub fn http_builder(
mut self,
f: impl Fn(reqwest::ClientBuilder) -> reqwest::ClientBuilder + 'static,
) -> Self {
self.http_builder = Some(Box::new(f));
self
}
}

impl From<String> for NodeConfig {
fn from(url: String) -> Self {
Self::new(url)
}
}

impl<'a> From<&'a str> for NodeConfig {
fn from(url: &'a str) -> Self {
Self::new(url)
}
}

impl From<reqwest::Url> for NodeConfig {
fn from(url: reqwest::Url) -> Self {
Self::new(url)
}
}

// This is an internal detail to track the state of each node.
#[derive(Debug)]
struct Node {
Expand Down Expand Up @@ -219,54 +321,64 @@
/// - **nearest_node**: None.
/// - **healthcheck_interval**: 60 seconds.
/// - **retry_policy**: Exponential backoff with a maximum of 3 retries. (disabled on WASM)
/// - **connection_timeout**: 5 seconds. (disabled on WASM)
/// - **http_builder**: An `Fn(reqwest::ClientBuilder) -> reqwest::ClientBuilder` closure
/// for per-node HTTP client customization (optional, via [`NodeConfig`]).
///
/// When no custom `http_builder` is configured, a default `reqwest::ClientBuilder` with
/// a 5-second connect timeout is used (native targets only).
#[builder]
pub fn new(
/// The Typesense API key used for authentication.
#[builder(into)]
api_key: String,
/// A list of all nodes in the Typesense cluster.
///
/// Accepts plain URL strings or [`NodeConfig`] instances for per-node
/// HTTP client customization.
#[builder(
with = |iter: impl IntoIterator<Item = impl Into<String>>|
iter.into_iter().map(Into::into).collect::<Vec<String>>()
with = |iter: impl IntoIterator<Item = impl Into<NodeConfig>>|
iter.into_iter().map(Into::into).collect::<Vec<NodeConfig>>()
)]
nodes: Vec<String>,
nodes: Vec<NodeConfig>,
#[builder(into)]
/// An optional, preferred node to try first for every request.
/// This is for your server-side load balancer.
/// Do not add this node to all nodes list, should be a separate one.
nearest_node: Option<String>,
nearest_node: Option<NodeConfig>,
#[builder(default = Duration::from_secs(60))]
/// The duration after which an unhealthy node will be retried for requests.
healthcheck_interval: Duration,
#[builder(default = ExponentialBackoff::builder().build_with_max_retries(3))]
/// The retry policy for transient network errors on a *single* node.
retry_policy: ExponentialBackoff,

Check warning on line 353 in typesense/src/client/mod.rs

View workflow job for this annotation

GitHub Actions / tests (ubuntu, wasm32-unknown-unknown)

unused variable: `retry_policy`

Check warning on line 353 in typesense/src/client/mod.rs

View workflow job for this annotation

GitHub Actions / tests (ubuntu, wasm32-unknown-unknown)

unused variable: `retry_policy`

Check warning on line 353 in typesense/src/client/mod.rs

View workflow job for this annotation

GitHub Actions / tests (ubuntu, wasm32-unknown-unknown)

unused variable: `retry_policy`
#[builder(default = Duration::from_secs(5))]
/// The timeout for each individual network request.
connection_timeout: Duration,
) -> Result<Self, &'static str> {
let is_nearest_node_set = nearest_node.is_some();

let nodes: Vec<_> = nodes
.into_iter()
.chain(nearest_node)
.map(|mut url| {
.map(|node_config| {
let builder = match node_config.http_builder {
Some(f) => f(reqwest::Client::builder()),
None => {
let b = reqwest::Client::builder();
#[cfg(not(target_arch = "wasm32"))]
let b = b.connect_timeout(Duration::from_secs(5));
b
}
};

#[cfg(target_arch = "wasm32")]
let http_client = reqwest::Client::builder()
.build()
.expect("Failed to build reqwest client");
let http_client = builder.build().expect("Failed to build reqwest client");

#[cfg(not(target_arch = "wasm32"))]
let http_client = ReqwestMiddlewareClientBuilder::new(
reqwest::Client::builder()
.timeout(connection_timeout)
.build()
.expect("Failed to build reqwest client"),
builder.build().expect("Failed to build reqwest client"),
)
.with(RetryTransientMiddleware::new_with_policy(retry_policy))
.build();

let mut url = node_config.url;
if url.len() > 1 && matches!(url.chars().last(), Some('/')) {
url.pop();
}
Expand Down
9 changes: 3 additions & 6 deletions typesense/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@
//! .api_key("xyz")
//! .healthcheck_interval(Duration::from_secs(60))
//! .retry_policy(ExponentialBackoff::builder().build_with_max_retries(3))
//! .connection_timeout(Duration::from_secs(5))
//! .build()?;
//!
//! // Create the collection in Typesense
Expand All @@ -68,8 +67,7 @@
//! ### WebAssembly (Wasm)
//!
//! This example is tailored for a WebAssembly target.
//! Key difference: Tokio-dependent features like `.retry_policy()` and `.connection_timeout()`
//! are disabled. You can still set them in the client builder but it will do nothing.
//! Key difference: Tokio-dependent features like `.retry_policy()` are disabled.
//!
//! ```no_run
//! #[cfg(target_family = "wasm")]
Expand Down Expand Up @@ -98,8 +96,7 @@
//! .nodes(vec!["http://localhost:8108"])
//! .api_key("xyz")
//! .healthcheck_interval(Duration::from_secs(60))
//! // .retry_policy(...) <-- disabled in Wasm
//! // .connection_timeout(...) <-- disabled in Wasm
//! // .retry_policy(...) <-- disabled in Wasm
//! .build()
//! .unwrap();
//!
Expand All @@ -119,7 +116,7 @@ pub mod error;
pub mod models;
pub mod prelude;

pub use client::{Client, ExponentialBackoff};
pub use client::{Client, ExponentialBackoff, NodeConfig};
pub use error::*;

pub use typesense_codegen as legacy;
Expand Down
2 changes: 0 additions & 2 deletions typesense/tests/client/client_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ fn get_client(nodes: Vec<String>, nearest_node: Option<String>) -> Client {
.api_key("test-key")
.healthcheck_interval(Duration::from_secs(60))
.retry_policy(ExponentialBackoff::builder().build_with_max_retries(0))
.connection_timeout(Duration::from_secs(1))
.build()
.expect("Failed to create client")
}
Expand Down Expand Up @@ -186,7 +185,6 @@ async fn test_health_check_and_node_recovery() {
.api_key("test-key")
.healthcheck_interval(Duration::from_millis(500)) // Use a very short healthcheck interval for the test
.retry_policy(ExponentialBackoff::builder().build_with_max_retries(0))
.connection_timeout(Duration::from_secs(1))
.build()
.expect("Failed to create client");

Expand Down
1 change: 0 additions & 1 deletion typesense/tests/client/conversation_models_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,6 @@ fn get_test_client(uri: &str) -> Client {
.api_key("TEST_API_KEY")
.healthcheck_interval(Duration::from_secs(60))
.retry_policy(ExponentialBackoff::builder().build_with_max_retries(0))
.connection_timeout(Duration::from_secs(1))
.build()
.expect("Failed to create client")
}
Expand Down
Loading
Loading