From a52b71d99f9cfed617351a9ccc4529ae42ace136 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Fri, 16 Jan 2026 15:58:19 -0500 Subject: [PATCH 01/15] feat!: Update StreamingDataSourceBuilder to require HttpTransport (#131) --- .github/workflows/ci.yml | 4 +- .github/workflows/manual-publish.yml | 2 +- .github/workflows/release-please.yml | 2 +- contract-tests/Cargo.toml | 10 ++- contract-tests/src/client_entity.rs | 11 ++- contract-tests/src/main.rs | 13 +++ launchdarkly-server-sdk/Cargo.toml | 7 +- launchdarkly-server-sdk/src/config.rs | 7 +- launchdarkly-server-sdk/src/data_source.rs | 21 ++--- .../src/data_source_builders.rs | 89 ++++++++----------- 10 files changed, 82 insertions(+), 84 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b93834e..ad2741e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: - name: Setup rust tooling run: | - rustup override set 1.83 + rustup override set 1.85 rustup component add rustfmt clippy - uses: ./.github/actions/ci @@ -49,7 +49,7 @@ jobs: - name: Setup rust tooling run: | - rustup override set 1.83 + rustup override set 1.85 rustup component add rustfmt clippy rustup target add x86_64-unknown-linux-musl diff --git a/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml index 9f350b0..6189f32 100644 --- a/.github/workflows/manual-publish.yml +++ b/.github/workflows/manual-publish.yml @@ -19,7 +19,7 @@ jobs: - name: Setup rust tooling run: | - rustup override set 1.83 + rustup override set 1.85 rustup component add rustfmt clippy - uses: ./.github/actions/ci diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 2078f4c..c6dfe21 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -24,7 +24,7 @@ jobs: - name: Setup rust tooling if: ${{ steps.release.outputs['launchdarkly-server-sdk--release_created'] == 'true' }} run: | - rustup override set 1.83 + rustup override set 1.85 rustup component add rustfmt clippy - uses: launchdarkly/gh-actions/actions/release-secrets@release-secrets-v1.2.0 diff --git a/contract-tests/Cargo.toml b/contract-tests/Cargo.toml index 228327a..1135866 100644 --- a/contract-tests/Cargo.toml +++ b/contract-tests/Cargo.toml @@ -2,13 +2,15 @@ name = "contract-tests" version = "0.1.0" edition = "2021" -rust-version = "1.83.0" # MSRV +rust-version = "1.85.0" # MSRV license = "Apache-2.0" [dependencies] actix = "0.13.0" actix-web = "4.2.1" env_logger = "0.10.0" +# eventsource-client = { version = "0.16.0", default-features = false } +eventsource-client = { git = "https://github.com/launchdarkly/rust-eventsource-client", branch = "feat/hyper-as-feature" } log = "0.4.14" launchdarkly-server-sdk = { path = "../launchdarkly-server-sdk/", default-features = false, features = ["event-compression"]} serde = { version = "1.0.132", features = ["derive"] } @@ -17,10 +19,12 @@ futures = "0.3.12" hyper = { version = "0.14.19", features = ["client"] } hyper-rustls = { version = "0.24.1" , optional = true, features = ["http2"]} hyper-tls = { version = "0.5.0", optional = true } +hyper1-tls = { package = "hyper-tls", version = "0.6.0", optional = true } +hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2", "tokio"], optional = true } reqwest = { version = "0.12.4", features = ["default", "blocking", "json"] } async-mutex = "1.4.0" [features] default = ["rustls"] -rustls = ["hyper-rustls/http1", "hyper-rustls/http2", "launchdarkly-server-sdk/rustls"] -tls = ["hyper-tls"] +rustls = ["hyper-rustls/http1", "hyper-rustls/http2", "launchdarkly-server-sdk/rustls", "hyper-util"] +tls = ["hyper-tls", "hyper1-tls", "hyper-util"] diff --git a/contract-tests/src/client_entity.rs b/contract-tests/src/client_entity.rs index 3538178..0bcb72e 100644 --- a/contract-tests/src/client_entity.rs +++ b/contract-tests/src/client_entity.rs @@ -1,3 +1,4 @@ +use eventsource_client as es; use futures::future::FutureExt; use launchdarkly_server_sdk::{ Context, ContextBuilder, MigratorBuilder, MultiContextBuilder, Reference, @@ -20,6 +21,7 @@ use crate::command_params::{ MigrationOperationResponse, MigrationVariationResponse, SecureModeHashResponse, }; use crate::HttpsConnector; +use crate::StreamingHttpsConnector; use crate::{ command_params::{ CommandParams, CommandResponse, EvaluateAllFlagsParams, EvaluateAllFlagsResponse, @@ -36,6 +38,7 @@ impl ClientEntity { pub async fn new( create_instance_params: CreateInstanceParams, connector: HttpsConnector, + streaming_https_connector: StreamingHttpsConnector, ) -> Result { let mut config_builder = ConfigBuilder::new(&create_instance_params.configuration.credential); @@ -71,6 +74,8 @@ impl ClientEntity { } if let Some(streaming) = create_instance_params.configuration.streaming { + let transport = + es::HyperTransport::builder().build_with_connector(streaming_https_connector); if let Some(base_uri) = streaming.base_uri { service_endpoints_builder.streaming_base_url(&base_uri); } @@ -79,7 +84,7 @@ impl ClientEntity { if let Some(delay) = streaming.initial_retry_delay_ms { streaming_builder.initial_reconnect_delay(Duration::from_millis(delay)); } - streaming_builder.https_connector(connector.clone()); + streaming_builder.transport(transport); config_builder = config_builder.data_source(&streaming_builder); } else if let Some(polling) = create_instance_params.configuration.polling { @@ -98,8 +103,10 @@ impl ClientEntity { // If we didn't specify streaming or polling, we fall back to basic streaming. The only // customization we provide is the https connector to support testing multiple // connectors. + let transport = + es::HyperTransport::builder().build_with_connector(streaming_https_connector); let mut streaming_builder = StreamingDataSourceBuilder::new(); - streaming_builder.https_connector(connector.clone()); + streaming_builder.transport(transport); config_builder = config_builder.data_source(&streaming_builder); } diff --git a/contract-tests/src/main.rs b/contract-tests/src/main.rs index 4bd4ff2..ad400f0 100644 --- a/contract-tests/src/main.rs +++ b/contract-tests/src/main.rs @@ -132,6 +132,7 @@ async fn create_client( let client_entity = match ClientEntity::new( create_instance_params.into_inner(), app_state.https_connector.clone(), + app_state.streaming_https_connector.clone(), ) .await { @@ -206,12 +207,19 @@ struct AppState { counter: Mutex, client_entities: Mutex>, https_connector: HttpsConnector, + streaming_https_connector: StreamingHttpsConnector, } #[cfg(feature = "rustls")] type HttpsConnector = hyper_rustls::HttpsConnector; +#[cfg(feature = "rustls")] +type StreamingHttpsConnector = hyper_util::client::legacy::connect::HttpConnector; + #[cfg(feature = "tls")] type HttpsConnector = hyper_tls::HttpsConnector; +#[cfg(feature = "tls")] +type StreamingHttpsConnector = + hyper1_tls::HttpsConnector; #[actix_web::main] async fn main() -> std::io::Result<()> { @@ -228,6 +236,8 @@ async fn main() -> std::io::Result<()> { let (tx, rx) = mpsc::channel::<()>(); + #[cfg(feature = "rustls")] + let streaming_https_connector = hyper_util::client::legacy::connect::HttpConnector::new(); #[cfg(feature = "rustls")] let connector = hyper_rustls::HttpsConnectorBuilder::new() .with_native_roots() @@ -236,6 +246,8 @@ async fn main() -> std::io::Result<()> { .enable_http2() .build(); + #[cfg(feature = "tls")] + let streaming_https_connector = hyper1_tls::HttpsConnector::new(); #[cfg(feature = "tls")] let connector = hyper_tls::HttpsConnector::new(); @@ -243,6 +255,7 @@ async fn main() -> std::io::Result<()> { counter: Mutex::new(0), client_entities: Mutex::new(HashMap::new()), https_connector: connector, + streaming_https_connector: streaming_https_connector, }); let server = HttpServer::new(move || { diff --git a/launchdarkly-server-sdk/Cargo.toml b/launchdarkly-server-sdk/Cargo.toml index fb86abf..55bae78 100644 --- a/launchdarkly-server-sdk/Cargo.toml +++ b/launchdarkly-server-sdk/Cargo.toml @@ -4,7 +4,7 @@ description = "LaunchDarkly Server-Side SDK" version = "2.6.2" authors = ["LaunchDarkly"] edition = "2021" -rust-version = "1.83.0" # MSRV +rust-version = "1.85.0" # MSRV license = "Apache-2.0" homepage = "https://docs.launchdarkly.com/sdk/server-side/rust" repository = "https://github.com/launchdarkly/rust-server-sdk" @@ -20,7 +20,8 @@ features = ["event-compression"] chrono = "0.4.19" crossbeam-channel = "0.5.1" data-encoding = "2.3.2" -eventsource-client = { version = "0.16.0", default-features = false } +# eventsource-client = { version = "0.16.0", default-features = false } +eventsource-client = { git = "https://github.com/launchdarkly/rust-eventsource-client", branch = "feat/hyper-as-feature" } futures = "0.3.12" lazy_static = "1.4.0" log = "0.4.14" @@ -54,7 +55,7 @@ testing_logger = "0.1.1" [features] default = ["rustls"] -rustls = ["hyper-rustls/http1", "hyper-rustls/http2", "eventsource-client/rustls"] +rustls = ["hyper-rustls/http1", "hyper-rustls/http2", "eventsource-client/hyper-rustls"] event-compression = ["flate2"] [[example]] diff --git a/launchdarkly-server-sdk/src/config.rs b/launchdarkly-server-sdk/src/config.rs index e264bff..8e738df 100644 --- a/launchdarkly-server-sdk/src/config.rs +++ b/launchdarkly-server-sdk/src/config.rs @@ -6,6 +6,7 @@ use crate::events::processor_builders::{ }; use crate::stores::store_builders::{DataStoreFactory, InMemoryDataStoreBuilder}; use crate::{ServiceEndpointsBuilder, StreamingDataSourceBuilder}; +use eventsource_client as es; use std::borrow::Borrow; @@ -301,9 +302,9 @@ impl ConfigBuilder { } Some(builder) => Ok(builder), #[cfg(feature = "rustls")] - None => Ok(Box::new(StreamingDataSourceBuilder::< - hyper_rustls::HttpsConnector, - >::new())), + None => Ok(Box::new( + StreamingDataSourceBuilder::::new(), + )), #[cfg(not(feature = "rustls"))] None => Err(BuildError::InvalidConfig( "data source builder required when rustls is disabled".into(), diff --git a/launchdarkly-server-sdk/src/data_source.rs b/launchdarkly-server-sdk/src/data_source.rs index 55c786f..a533281 100644 --- a/launchdarkly-server-sdk/src/data_source.rs +++ b/launchdarkly-server-sdk/src/data_source.rs @@ -7,15 +7,11 @@ use crate::LAUNCHDARKLY_TAGS_HEADER; use es::{Client, ClientBuilder, ReconnectOptionsBuilder}; use eventsource_client as es; use futures::StreamExt; -use hyper::client::connect::Connection; -use hyper::service::Service; -use hyper::Uri; use launchdarkly_server_sdk_evaluation::{Flag, Segment}; use parking_lot::RwLock; use serde::Deserialize; use std::sync::{Arc, Mutex, Once}; use std::time::Duration; -use tokio::io::{AsyncRead, AsyncWrite}; use tokio::sync::broadcast; use tokio::time; use tokio_stream::wrappers::{BroadcastStream, IntervalStream}; @@ -73,19 +69,13 @@ pub struct StreamingDataSource { } impl StreamingDataSource { - pub fn new( + pub fn new( base_url: &str, sdk_key: &str, initial_reconnect_delay: Duration, tags: &Option, - connector: C, - ) -> std::result::Result - where - C: Service + Clone + Send + Sync + 'static, - C::Response: Connection + AsyncRead + AsyncWrite + Send + Unpin, - C::Future: Send + 'static, - C::Error: Into>, - { + transport: T, + ) -> std::result::Result { let stream_url = format!("{}/all", base_url); let client_builder = ClientBuilder::for_url(&stream_url)?; @@ -105,7 +95,7 @@ impl StreamingDataSource { } Ok(Self { - es_client: Box::new(client_builder.build_with_conn(connector)), + es_client: Box::new(client_builder.build_with_transport(transport)), }) } } @@ -385,6 +375,7 @@ mod tests { use super::{DataSource, PollingDataSource, StreamingDataSource}; use crate::feature_requester_builders::HyperFeatureRequesterBuilder; use crate::{stores::store::InMemoryDataStore, LAUNCHDARKLY_TAGS_HEADER}; + use eventsource_client as es; #[test_case(Some("application-id/abc:application-sha/xyz".into()), "application-id/abc:application-sha/xyz")] #[test_case(None, Matcher::Missing)] @@ -411,7 +402,7 @@ mod tests { "sdk-key", Duration::from_secs(0), &tag, - HttpConnector::new(), + es::HyperTransport::new(), ) .unwrap(); diff --git a/launchdarkly-server-sdk/src/data_source_builders.rs b/launchdarkly-server-sdk/src/data_source_builders.rs index ec32390..2d1d566 100644 --- a/launchdarkly-server-sdk/src/data_source_builders.rs +++ b/launchdarkly-server-sdk/src/data_source_builders.rs @@ -1,6 +1,7 @@ use super::service_endpoints; use crate::data_source::{DataSource, NullDataSource, PollingDataSource, StreamingDataSource}; use crate::feature_requester_builders::{FeatureRequesterFactory, HyperFeatureRequesterBuilder}; +use eventsource_client as es; use hyper::{client::connect::Connection, service::Service, Uri}; #[cfg(feature = "rustls")] use hyper_rustls::HttpsConnectorBuilder; @@ -47,26 +48,25 @@ pub trait DataSourceFactory { /// Adjust the initial reconnect delay. /// ``` /// # use launchdarkly_server_sdk::{StreamingDataSourceBuilder, ConfigBuilder}; -/// # use hyper_rustls::HttpsConnector; -/// # use hyper::client::HttpConnector; +/// # use eventsource_client as es; /// # use std::time::Duration; /// # fn main() { -/// ConfigBuilder::new("sdk-key").data_source(StreamingDataSourceBuilder::>::new() +/// ConfigBuilder::new("sdk-key").data_source(StreamingDataSourceBuilder::::new() /// .initial_reconnect_delay(Duration::from_secs(10))); /// # } /// ``` #[derive(Clone)] -pub struct StreamingDataSourceBuilder { +pub struct StreamingDataSourceBuilder { initial_reconnect_delay: Duration, - connector: Option, + transport: Option, } -impl StreamingDataSourceBuilder { +impl StreamingDataSourceBuilder { /// Create a new instance of the [StreamingDataSourceBuilder] with default values. pub fn new() -> Self { Self { initial_reconnect_delay: DEFAULT_INITIAL_RECONNECT_DELAY, - connector: None, + transport: None, } } @@ -76,56 +76,42 @@ impl StreamingDataSourceBuilder { self } - /// Sets the connector for the event source client to use. This allows for re-use of a - /// connector between multiple client instances. This is especially useful for the + /// Sets the transport for the event source client to use. This allows for re-use of a + /// transport between multiple client instances. This is especially useful for the /// `sdk-test-harness` where many client instances are created throughout the test and reading /// the native certificates is a substantial portion of the runtime. - pub fn https_connector(&mut self, connector: C) -> &mut Self { - self.connector = Some(connector); + pub fn transport(&mut self, transport: T) -> &mut Self { + self.transport = Some(transport); self } } -impl DataSourceFactory for StreamingDataSourceBuilder -where - C: Service + Clone + Send + Sync + 'static, - C::Response: Connection + AsyncRead + AsyncWrite + Send + Unpin, - C::Future: Send + 'static, - C::Error: Into>, -{ +impl DataSourceFactory for StreamingDataSourceBuilder { fn build( &self, endpoints: &service_endpoints::ServiceEndpoints, sdk_key: &str, tags: Option, ) -> Result, BuildError> { - let data_source_result = match &self.connector { + let data_source_result = match &self.transport { #[cfg(feature = "rustls")] - None => { - let connector = HttpsConnectorBuilder::new() - .with_native_roots() - .https_or_http() - .enable_http1() - .enable_http2() - .build(); - Ok(StreamingDataSource::new( - endpoints.streaming_base_url(), - sdk_key, - self.initial_reconnect_delay, - &tags, - connector, - )) - } + None => Ok(StreamingDataSource::new( + endpoints.streaming_base_url(), + sdk_key, + self.initial_reconnect_delay, + &tags, + es::HyperTransport::new_https(), + )), #[cfg(not(feature = "rustls"))] None => Err(BuildError::InvalidConfig( "https connector required when rustls is disabled".into(), )), - Some(connector) => Ok(StreamingDataSource::new( + Some(transport) => Ok(StreamingDataSource::new( endpoints.streaming_base_url(), sdk_key, self.initial_reconnect_delay, &tags, - connector.clone(), + transport.clone(), )), }; let data_source = data_source_result? @@ -138,7 +124,7 @@ where } } -impl Default for StreamingDataSourceBuilder { +impl Default for StreamingDataSourceBuilder { fn default() -> Self { StreamingDataSourceBuilder::new() } @@ -355,13 +341,15 @@ impl DataSourceFactory for MockDataSourceBuilder { #[cfg(test)] mod tests { + use eventsource_client::{HyperTransport, ResponseFuture}; use hyper::client::HttpConnector; use super::*; #[test] fn default_stream_builder_has_correct_defaults() { - let builder: StreamingDataSourceBuilder = StreamingDataSourceBuilder::new(); + let builder: StreamingDataSourceBuilder = + StreamingDataSourceBuilder::new(); assert_eq!( builder.initial_reconnect_delay, @@ -370,29 +358,22 @@ mod tests { } #[test] - fn stream_builder_can_use_custom_connector() { + fn stream_builder_can_use_custom_transport() { #[derive(Debug, Clone)] - struct TestConnector; - impl hyper::service::Service for TestConnector { - type Response = tokio::net::TcpStream; - type Error = std::io::Error; - type Future = futures::future::BoxFuture<'static, Result>; - - fn poll_ready( - &mut self, - _cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - std::task::Poll::Ready(Ok(())) - } + struct TestTransport; - fn call(&mut self, _req: hyper::Uri) -> Self::Future { + impl es::HttpTransport for TestTransport { + fn request( + &self, + _request: eventsource_client::Request>, + ) -> ResponseFuture { // this won't be called during the test unreachable!(); } } let mut builder = StreamingDataSourceBuilder::new(); - builder.https_connector(TestConnector); + builder.transport(TestTransport); assert!(builder .build( &crate::ServiceEndpointsBuilder::new().build().unwrap(), @@ -410,7 +391,7 @@ mod tests { #[test] fn initial_reconnect_delay_for_streaming_can_be_adjusted() { - let mut builder = StreamingDataSourceBuilder::<()>::new(); + let mut builder = StreamingDataSourceBuilder::::new(); builder.initial_reconnect_delay(Duration::from_secs(1234)); assert_eq!(builder.initial_reconnect_delay, Duration::from_secs(1234)); } From a0528ba73a48a621d5d4c7646a5b20b22cc03343 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Tue, 20 Jan 2026 10:41:19 -0500 Subject: [PATCH 02/15] feat!: Upgrade to hyper 1.0 (#135) Update from hyper 0.14 to hyper 1.0 ecosystem, matching the eventsource-client dependency. This includes: - Upgrade http crate from 0.2 to 1.0 - Use hyper-util for legacy Client API - Use http-body-util for body types - Update hyper-rustls from 0.24 to 0.27 - Update all trait bounds and type annotations - Add tower as direct dependency for Service trait BREAKING CHANGE: hyper-related trait bounds have changed for custom connector implementations. The Future associated type now requires Unpin bound, and trait bounds have been updated to use tower::Service and hyper_util::client::legacy::connect types. --- contract-tests/Cargo.toml | 12 ++-- contract-tests/src/client_entity.rs | 2 +- contract-tests/src/main.rs | 12 ++-- launchdarkly-server-sdk/Cargo.toml | 9 ++- .../examples/print_flags.rs | 2 +- launchdarkly-server-sdk/src/client.rs | 39 +++-------- launchdarkly-server-sdk/src/config.rs | 6 +- launchdarkly-server-sdk/src/data_source.rs | 14 ++-- .../src/data_source_builders.rs | 23 ++++--- .../src/events/dispatcher.rs | 10 +-- launchdarkly-server-sdk/src/events/event.rs | 6 +- .../src/events/processor_builders.rs | 23 ++++--- launchdarkly-server-sdk/src/events/sender.rs | 64 ++++++++++++------- .../src/feature_requester.rs | 60 +++++++++++------ .../src/feature_requester_builders.rs | 36 +++++++---- launchdarkly-server-sdk/src/migrations/mod.rs | 2 +- .../src/migrations/tracker.rs | 6 +- .../src/stores/persistent_store_wrapper.rs | 21 +++--- launchdarkly-server-sdk/src/stores/store.rs | 2 +- 19 files changed, 195 insertions(+), 154 deletions(-) diff --git a/contract-tests/Cargo.toml b/contract-tests/Cargo.toml index 1135866..2e1cf4a 100644 --- a/contract-tests/Cargo.toml +++ b/contract-tests/Cargo.toml @@ -16,15 +16,13 @@ launchdarkly-server-sdk = { path = "../launchdarkly-server-sdk/", default-featur serde = { version = "1.0.132", features = ["derive"] } serde_json = "1.0.73" futures = "0.3.12" -hyper = { version = "0.14.19", features = ["client"] } -hyper-rustls = { version = "0.24.1" , optional = true, features = ["http2"]} -hyper-tls = { version = "0.5.0", optional = true } -hyper1-tls = { package = "hyper-tls", version = "0.6.0", optional = true } -hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2", "tokio"], optional = true } +hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2", "tokio"] } +hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "webpki-roots"], optional = true } +hyper-tls = { version = "0.6.0", optional = true } reqwest = { version = "0.12.4", features = ["default", "blocking", "json"] } async-mutex = "1.4.0" [features] default = ["rustls"] -rustls = ["hyper-rustls/http1", "hyper-rustls/http2", "launchdarkly-server-sdk/rustls", "hyper-util"] -tls = ["hyper-tls", "hyper1-tls", "hyper-util"] +rustls = ["hyper-rustls", "launchdarkly-server-sdk/rustls"] +tls = ["hyper-tls"] diff --git a/contract-tests/src/client_entity.rs b/contract-tests/src/client_entity.rs index 0bcb72e..f4931e7 100644 --- a/contract-tests/src/client_entity.rs +++ b/contract-tests/src/client_entity.rs @@ -357,7 +357,7 @@ impl ClientEntity { )), } } - command => Err(format!("Invalid command requested: {}", command)), + command => Err(format!("Invalid command requested: {command}")), } } diff --git a/contract-tests/src/main.rs b/contract-tests/src/main.rs index ad400f0..e4b1b36 100644 --- a/contract-tests/src/main.rs +++ b/contract-tests/src/main.rs @@ -7,7 +7,7 @@ use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer, Responder, Resu use async_mutex::Mutex; use client_entity::ClientEntity; use futures::executor; -use hyper::client::HttpConnector; +use hyper_util::client::legacy::connect::HttpConnector; use launchdarkly_server_sdk::Reference; use serde::{self, Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; @@ -137,7 +137,7 @@ async fn create_client( .await { Ok(ce) => ce, - Err(e) => return HttpResponse::InternalServerError().body(format!("{}", e)), + Err(e) => return HttpResponse::InternalServerError().body(format!("{e}")), }; let mut counter = app_state.counter.lock().await; @@ -218,8 +218,7 @@ type StreamingHttpsConnector = hyper_util::client::legacy::connect::HttpConnecto #[cfg(feature = "tls")] type HttpsConnector = hyper_tls::HttpsConnector; #[cfg(feature = "tls")] -type StreamingHttpsConnector = - hyper1_tls::HttpsConnector; +type StreamingHttpsConnector = hyper_tls::HttpsConnector; #[actix_web::main] async fn main() -> std::io::Result<()> { @@ -241,13 +240,14 @@ async fn main() -> std::io::Result<()> { #[cfg(feature = "rustls")] let connector = hyper_rustls::HttpsConnectorBuilder::new() .with_native_roots() + .expect("Failed to load native root certificates") .https_or_http() .enable_http1() .enable_http2() .build(); #[cfg(feature = "tls")] - let streaming_https_connector = hyper1_tls::HttpsConnector::new(); + let streaming_https_connector = hyper_tls::HttpsConnector::new(); #[cfg(feature = "tls")] let connector = hyper_tls::HttpsConnector::new(); @@ -255,7 +255,7 @@ async fn main() -> std::io::Result<()> { counter: Mutex::new(0), client_entities: Mutex::new(HashMap::new()), https_connector: connector, - streaming_https_connector: streaming_https_connector, + streaming_https_connector, }); let server = HttpServer::new(move || { diff --git a/launchdarkly-server-sdk/Cargo.toml b/launchdarkly-server-sdk/Cargo.toml index 55bae78..9e6e2b8 100644 --- a/launchdarkly-server-sdk/Cargo.toml +++ b/launchdarkly-server-sdk/Cargo.toml @@ -35,8 +35,13 @@ parking_lot = "0.12.0" tokio-stream = { version = "0.1.8", features = ["sync"] } moka = { version = "0.12.1", features = ["sync"] } uuid = {version = "1.2.2", features = ["v4"] } -hyper = { version = "0.14.19", features = ["client", "http1", "http2", "tcp"] } -hyper-rustls = { version = "0.24.1" , optional = true} +http = "1.0" +bytes = "1.11" +hyper = { version = "1.0", features = ["client", "http1", "http2"] } +hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2", "tokio"] } +http-body-util = { version = "0.1" } +hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "webpki-roots"], optional = true} +tower = { version = "0.4" } rand = "0.9" flate2 = { version = "1.0.35", optional = true } aws-lc-rs = "1.14.1" diff --git a/launchdarkly-server-sdk/examples/print_flags.rs b/launchdarkly-server-sdk/examples/print_flags.rs index 5a7c3b3..168d918 100644 --- a/launchdarkly-server-sdk/examples/print_flags.rs +++ b/launchdarkly-server-sdk/examples/print_flags.rs @@ -27,7 +27,7 @@ async fn main() { } else if let ["str", name] = bits { str_flags.push(name.to_string()); } else if let [flag_type, _] = bits { - error!("Unsupported flag type {} in {}", flag_type, flag); + error!("Unsupported flag type {flag_type} in {flag}"); exit(2); } else if let [name] = bits { bool_flags.push(name.to_string()); diff --git a/launchdarkly-server-sdk/src/client.rs b/launchdarkly-server-sdk/src/client.rs index bd3d6d9..a157c65 100644 --- a/launchdarkly-server-sdk/src/client.rs +++ b/launchdarkly-server-sdk/src/client.rs @@ -294,10 +294,7 @@ impl Client { } let initialized = tokio::time::timeout(timeout, self.initialized_async_internal()).await; - match initialized { - Ok(result) => Some(result), - Err(_) => None, - } + initialized.ok() } async fn initialized_async_internal(&self) -> bool { @@ -335,7 +332,7 @@ impl Client { // broadcast channel, so sending on it would always result in an error. if !self.offline && !self.daemon_mode { if let Err(e) = self.shutdown_broadcast.send(()) { - error!("Failed to shutdown client appropriately: {}", e); + error!("Failed to shutdown client appropriately: {e}"); } } @@ -379,10 +376,7 @@ impl Client { if let Some(b) = val.as_bool() { b } else { - warn!( - "bool_variation called for a non-bool flag {:?} (got {:?})", - flag_key, val - ); + warn!("bool_variation called for a non-bool flag {flag_key:?} (got {val:?})"); default } } @@ -399,10 +393,7 @@ impl Client { if let Some(s) = val.as_string() { s } else { - warn!( - "str_variation called for a non-string flag {:?} (got {:?})", - flag_key, val - ); + warn!("str_variation called for a non-string flag {flag_key:?} (got {val:?})"); default } } @@ -419,10 +410,7 @@ impl Client { if let Some(f) = val.as_float() { f } else { - warn!( - "float_variation called for a non-float flag {:?} (got {:?})", - flag_key, val - ); + warn!("float_variation called for a non-float flag {flag_key:?} (got {val:?})"); default } } @@ -439,10 +427,7 @@ impl Client { if let Some(f) = val.as_int() { f } else { - warn!( - "int_variation called for a non-int flag {:?} (got {:?})", - flag_key, val - ); + warn!("int_variation called for a non-int flag {flag_key:?} (got {val:?})"); default } } @@ -760,16 +745,10 @@ impl Client { self.events_default.event_factory.new_migration_op(event), ); } - Err(e) => error!( - "Failed to build migration event, no event will be sent: {}", - e - ), + Err(e) => error!("Failed to build migration event, no event will be sent: {e}"), } } - Err(e) => error!( - "Failed to lock migration tracker, no event will be sent: {}", - e - ), + Err(e) => error!("Failed to lock migration tracker, no event will be sent: {e}"), } } @@ -849,7 +828,7 @@ mod tests { use crossbeam_channel::Receiver; use eval::{ContextBuilder, MultiContextBuilder}; use futures::FutureExt; - use hyper::client::HttpConnector; + use hyper_util::client::legacy::connect::HttpConnector; use launchdarkly_server_sdk_evaluation::{Flag, Reason, Segment}; use maplit::hashmap; use std::collections::HashMap; diff --git a/launchdarkly-server-sdk/src/config.rs b/launchdarkly-server-sdk/src/config.rs index 8e738df..846fb98 100644 --- a/launchdarkly-server-sdk/src/config.rs +++ b/launchdarkly-server-sdk/src/config.rs @@ -86,7 +86,7 @@ impl ApplicationInfo { match tag.is_valid() { Ok(_) => self.tags.push(tag), Err(e) => { - warn!("{}", e) + warn!("{e}") } } @@ -322,7 +322,9 @@ impl ConfigBuilder { Some(builder) => Ok(builder), #[cfg(feature = "rustls")] None => Ok(Box::new(EventProcessorBuilder::< - hyper_rustls::HttpsConnector, + hyper_rustls::HttpsConnector< + hyper_util::client::legacy::connect::HttpConnector, + >, >::new())), #[cfg(not(feature = "rustls"))] None => Err(BuildError::InvalidConfig( diff --git a/launchdarkly-server-sdk/src/data_source.rs b/launchdarkly-server-sdk/src/data_source.rs index a533281..022abcc 100644 --- a/launchdarkly-server-sdk/src/data_source.rs +++ b/launchdarkly-server-sdk/src/data_source.rs @@ -76,7 +76,7 @@ impl StreamingDataSource { tags: &Option, transport: T, ) -> std::result::Result { - let stream_url = format!("{}/all", base_url); + let stream_url = format!("{base_url}/all"); let client_builder = ClientBuilder::for_url(&stream_url)?; let mut client_builder = client_builder @@ -126,7 +126,7 @@ impl DataSource for StreamingDataSource { continue; }, es::SSE::Comment(str)=> { - debug!("data source got a comment: {}", str); + debug!("data source got a comment: {str}"); continue; }, es::SSE::Event(ev) => ev, @@ -147,7 +147,7 @@ impl DataSource for StreamingDataSource { continue; } _ => { - error!("unhandled error on event stream: {:?}", e); + error!("unhandled error on event stream: {e:?}"); break; } } @@ -171,7 +171,7 @@ impl DataSource for StreamingDataSource { }; if let Err(e) = stored { init_success = false; - error!("error processing update: {:?}", e); + error!("error processing update: {e:?}"); } notify_init.call_once(|| (init_complete)(init_success)); @@ -213,12 +213,12 @@ impl DataSource for PollingDataSource { Ok(factory) => match factory.build(self.tags.clone()) { Ok(requester) => requester, Err(e) => { - error!("{:?}", e); + error!("{e:?}"); return; } }, Err(e) => { - error!("{:?}", e); + error!("{e:?}"); return; } }; @@ -366,7 +366,7 @@ mod tests { time::Duration, }; - use hyper::client::HttpConnector; + use hyper_util::client::legacy::connect::HttpConnector; use mockito::Matcher; use parking_lot::RwLock; use test_case::test_case; diff --git a/launchdarkly-server-sdk/src/data_source_builders.rs b/launchdarkly-server-sdk/src/data_source_builders.rs index 2d1d566..db2f258 100644 --- a/launchdarkly-server-sdk/src/data_source_builders.rs +++ b/launchdarkly-server-sdk/src/data_source_builders.rs @@ -2,13 +2,12 @@ use super::service_endpoints; use crate::data_source::{DataSource, NullDataSource, PollingDataSource, StreamingDataSource}; use crate::feature_requester_builders::{FeatureRequesterFactory, HyperFeatureRequesterBuilder}; use eventsource_client as es; -use hyper::{client::connect::Connection, service::Service, Uri}; +use http::Uri; #[cfg(feature = "rustls")] use hyper_rustls::HttpsConnectorBuilder; use std::sync::{Arc, Mutex}; use std::time::Duration; use thiserror::Error; -use tokio::io::{AsyncRead, AsyncWrite}; #[cfg(test)] use super::data_source; @@ -115,7 +114,7 @@ impl DataSourceFactory for StreamingDataSourceBuilder { )), }; let data_source = data_source_result? - .map_err(|e| BuildError::InvalidConfig(format!("invalid stream_base_url: {:?}", e)))?; + .map_err(|e| BuildError::InvalidConfig(format!("invalid stream_base_url: {e:?}")))?; Ok(Arc::new(data_source)) } @@ -176,7 +175,7 @@ impl Default for NullDataSourceBuilder { /// ``` /// # use launchdarkly_server_sdk::{PollingDataSourceBuilder, ConfigBuilder}; /// # use hyper_rustls::HttpsConnector; -/// # use hyper::client::HttpConnector; +/// # use hyper_util::client::legacy::connect::HttpConnector; /// # use std::time::Duration; /// # fn main() { /// ConfigBuilder::new("sdk-key").data_source(PollingDataSourceBuilder::>::new() @@ -207,7 +206,7 @@ pub struct PollingDataSourceBuilder { /// # use launchdarkly_server_sdk::{PollingDataSourceBuilder, ConfigBuilder}; /// # use std::time::Duration; /// # use hyper_rustls::HttpsConnector; -/// # use hyper::client::HttpConnector; +/// # use hyper_util::client::legacy::connect::HttpConnector; /// # fn main() { /// ConfigBuilder::new("sdk-key").data_source(PollingDataSourceBuilder::>::new() /// .poll_interval(Duration::from_secs(60))); @@ -243,8 +242,12 @@ impl PollingDataSourceBuilder { impl DataSourceFactory for PollingDataSourceBuilder where - C: Service + Clone + Send + Sync + 'static, - C::Response: Connection + AsyncRead + AsyncWrite + Send + Unpin, + C: tower::Service + Clone + Send + Sync + 'static, + C::Response: hyper_util::client::legacy::connect::Connection + + hyper::rt::Read + + hyper::rt::Write + + Send + + Unpin, C::Future: Send + Unpin + 'static, C::Error: Into>, { @@ -260,6 +263,10 @@ where None => { let connector = HttpsConnectorBuilder::new() .with_native_roots() + .unwrap_or_else(|_| { + log::debug!("Falling back to webpki roots for polling HTTPS connector"); + HttpsConnectorBuilder::new().with_webpki_roots() + }) .https_or_http() .enable_http1() .enable_http2() @@ -342,7 +349,7 @@ impl DataSourceFactory for MockDataSourceBuilder { #[cfg(test)] mod tests { use eventsource_client::{HyperTransport, ResponseFuture}; - use hyper::client::HttpConnector; + use hyper_util::client::legacy::connect::HttpConnector; use super::*; diff --git a/launchdarkly-server-sdk/src/events/dispatcher.rs b/launchdarkly-server-sdk/src/events/dispatcher.rs index df59165..5921fb5 100644 --- a/launchdarkly-server-sdk/src/events/dispatcher.rs +++ b/launchdarkly-server-sdk/src/events/dispatcher.rs @@ -108,7 +108,7 @@ impl EventDispatcher { let rt = match rt { Ok(rt) => rt, Err(e) => { - error!("Could not start runtime for event sending: {}", e); + error!("Could not start runtime for event sending: {e}"); return; } }; @@ -128,7 +128,7 @@ impl EventDispatcher { }, Ok(_) => continue, Err(e) => { - error!("event_result_rx is disconnected. Shutting down dispatcher: {}", e); + error!("event_result_rx is disconnected. Shutting down dispatcher: {e}"); return; } }, @@ -158,7 +158,7 @@ impl EventDispatcher { return; } Err(e) => { - error!("inbox_rx is disconnected. Shutting down dispatcher: {}", e); + error!("inbox_rx is disconnected. Shutting down dispatcher: {e}"); return; } } @@ -302,11 +302,11 @@ impl EventDispatcher { let key = context.canonical_key(); if self.context_keys.get(key).is_none() { - trace!("noticing new context {:?}", key); + trace!("noticing new context {key:?}"); self.context_keys.put(key.to_owned(), ()); true } else { - trace!("ignoring already-seen context {:?}", key); + trace!("ignoring already-seen context {key:?}"); false } } diff --git a/launchdarkly-server-sdk/src/events/event.rs b/launchdarkly-server-sdk/src/events/event.rs index 741694b..665f08c 100644 --- a/launchdarkly-server-sdk/src/events/event.rs +++ b/launchdarkly-server-sdk/src/events/event.rs @@ -476,8 +476,8 @@ impl InputEvent { impl Display for InputEvent { fn fmt(&self, f: &mut Formatter) -> fmt::Result { let json = serde_json::to_string_pretty(self) - .unwrap_or_else(|e| format!("JSON serialization failed ({}): {:?}", e, self)); - write!(f, "{}", json) + .unwrap_or_else(|e| format!("JSON serialization failed ({e}): {self:?}")); + write!(f, "{json}") } } @@ -1600,7 +1600,7 @@ mod tests { variation_index: Some(1), reason: Reason::RuleMatch { rule_index, - rule_id: format!("rule-{}", rule_index), + rule_id: format!("rule-{rule_index}"), in_experiment: rule_in_experiment, }, }; diff --git a/launchdarkly-server-sdk/src/events/processor_builders.rs b/launchdarkly-server-sdk/src/events/processor_builders.rs index 75b7e8e..d7ea7be 100644 --- a/launchdarkly-server-sdk/src/events/processor_builders.rs +++ b/launchdarkly-server-sdk/src/events/processor_builders.rs @@ -4,14 +4,11 @@ use std::str::FromStr; use std::sync::Arc; use std::time::Duration; -use hyper::client::connect::Connection; -use hyper::service::Service; -use hyper::Uri; +use http::Uri; #[cfg(feature = "rustls")] use hyper_rustls::HttpsConnectorBuilder; use launchdarkly_server_sdk_evaluation::Reference; use thiserror::Error; -use tokio::io::{AsyncRead, AsyncWrite}; use crate::events::sender::HyperEventSender; use crate::{service_endpoints, LAUNCHDARKLY_TAGS_HEADER}; @@ -66,7 +63,7 @@ pub trait EventProcessorFactory { /// ``` /// # use launchdarkly_server_sdk::{EventProcessorBuilder, ConfigBuilder}; /// # use hyper_rustls::HttpsConnector; -/// # use hyper::client::HttpConnector; +/// # use hyper_util::client::legacy::connect::HttpConnector; /// # use std::time::Duration; /// # fn main() { /// ConfigBuilder::new("sdk-key").event_processor(EventProcessorBuilder::>::new() @@ -90,8 +87,12 @@ pub struct EventProcessorBuilder { impl EventProcessorFactory for EventProcessorBuilder where - C: Service + Clone + Send + Sync + 'static, - C::Response: Connection + AsyncRead + AsyncWrite + Send + Unpin, + C: tower::Service + Clone + Send + Sync + 'static, + C::Response: hyper_util::client::legacy::connect::Connection + + hyper::rt::Read + + hyper::rt::Write + + Send + + Unpin, C::Future: Send + Unpin + 'static, C::Error: Into>, { @@ -126,6 +127,10 @@ where { let connector = HttpsConnectorBuilder::new() .with_native_roots() + .unwrap_or_else(|_| { + log::debug!("Falling back to webpki roots for event HTTPS connector"); + HttpsConnectorBuilder::new().with_webpki_roots() + }) .https_or_http() .enable_http1() .enable_http2() @@ -133,7 +138,7 @@ where Ok(Arc::new(HyperEventSender::new( connector, - hyper::Uri::from_str(url_string.as_str()).unwrap(), + Uri::from_str(url_string.as_str()).unwrap(), sdk_key, default_headers, self.compress_events, @@ -324,7 +329,7 @@ impl Default for NullEventProcessorBuilder { #[cfg(test)] mod tests { - use hyper::client::HttpConnector; + use hyper_util::client::legacy::connect::HttpConnector; use launchdarkly_server_sdk_evaluation::ContextBuilder; use maplit::hashset; use mockito::Matcher; diff --git a/launchdarkly-server-sdk/src/events/sender.rs b/launchdarkly-server-sdk/src/events/sender.rs index f8b6a54..4a4fbd8 100644 --- a/launchdarkly-server-sdk/src/events/sender.rs +++ b/launchdarkly-server-sdk/src/events/sender.rs @@ -13,12 +13,11 @@ use flate2::Compression; #[cfg(feature = "event-compression")] use std::io::Write; +use bytes::Bytes; use futures::future::BoxFuture; -use hyper::{client::connect::Connection, service::Service, Uri}; -use tokio::{ - io::{AsyncRead, AsyncWrite}, - time::{sleep, Duration}, -}; +use http_body_util::{BodyExt, Full}; +use hyper_util::{client::legacy::Client as HyperClient, rt::TokioExecutor}; +use tokio::time::{sleep, Duration}; use uuid::Uuid; use super::event::OutputEvent; @@ -39,9 +38,12 @@ pub trait EventSender: Send + Sync { #[derive(Clone)] pub struct HyperEventSender { - url: hyper::Uri, + url: http::Uri, sdk_key: String, - http: hyper::Client, + http: HyperClient< + C, + http_body_util::combinators::BoxBody>, + >, default_headers: HashMap<&'static str, String>, // used with event-compression feature @@ -51,14 +53,18 @@ pub struct HyperEventSender { impl HyperEventSender where - C: Service + Clone + Send + Sync + 'static, - C::Response: Connection + AsyncRead + AsyncWrite + Send + Unpin, + C: tower::Service + Clone + Send + Sync + 'static, + C::Response: hyper_util::client::legacy::connect::Connection + + hyper::rt::Read + + hyper::rt::Write + + Send + + Unpin, C::Future: Send + Unpin + 'static, C::Error: Into>, { pub fn new( connector: C, - url: hyper::Uri, + url: http::Uri, sdk_key: &str, default_headers: HashMap<&'static str, String>, compress_events: bool, @@ -66,13 +72,13 @@ where Self { url, sdk_key: sdk_key.to_owned(), - http: hyper::Client::builder().build(connector), + http: HyperClient::builder(TokioExecutor::new()).build(connector), default_headers, compress_events, } } - fn get_server_time_from_response(&self, response: &hyper::Response) -> u128 { + fn get_server_time_from_response(&self, response: &http::Response) -> u128 { let date_value = response .headers() .get("date") @@ -90,8 +96,12 @@ where impl EventSender for HyperEventSender where - C: Service + Clone + Send + Sync + 'static, - C::Response: Connection + AsyncRead + AsyncWrite + Send + Unpin, + C: tower::Service + Clone + Send + Sync + 'static, + C::Response: hyper_util::client::legacy::connect::Connection + + hyper::rt::Read + + hyper::rt::Write + + Send + + Unpin, C::Future: Send + Unpin + 'static, C::Error: Into>, { @@ -114,10 +124,7 @@ where let mut payload = match serde_json::to_vec(&events) { Ok(json) => json, Err(e) => { - error!( - "Failed to serialize event payload. Some events were dropped: {:?}", - e - ); + error!("Failed to serialize event payload. Some events were dropped: {e:?}"); return; } }; @@ -158,7 +165,16 @@ where request_builder = request_builder.header(*default_header.0, default_header.1.as_str()); } - let request = request_builder.body(hyper::Body::from(payload.clone())); + + // Convert Vec to BoxBody for hyper 1.0 + let body_bytes = Bytes::from(payload.clone()); + let boxed_body: http_body_util::combinators::BoxBody< + Bytes, + Box, + > = Full::new(body_bytes) + .map_err(|e| Box::new(e) as Box) + .boxed(); + let request = request_builder.body(boxed_body); let result = self.http.request(request.unwrap()).await; @@ -168,7 +184,7 @@ where Err(e) => { // It appears this type of error will not be an HTTP error. // It will be a closed connection, aborted write, timeout, etc. - error!("Failed to send events. Some events were dropped: {:?}", e); + error!("Failed to send events. Some events were dropped: {e:?}"); result_tx .send(EventSenderResult { success: false, @@ -355,12 +371,14 @@ mod tests { assert_eq!(sender_result.time_from_server, 1234567890000); } - fn build_event_sender(url: String) -> HyperEventSender { + fn build_event_sender( + url: String, + ) -> HyperEventSender { let url = format!("{}/bulk", &url); - let url = hyper::Uri::from_str(&url).expect("Failed parsing the mock server url"); + let url = http::Uri::from_str(&url).expect("Failed parsing the mock server url"); HyperEventSender::new( - hyper::client::HttpConnector::new(), + hyper_util::client::legacy::connect::HttpConnector::new(), url, "sdk-key", HashMap::<&str, String>::new(), diff --git a/launchdarkly-server-sdk/src/feature_requester.rs b/launchdarkly-server-sdk/src/feature_requester.rs index e435036..41bb197 100644 --- a/launchdarkly-server-sdk/src/feature_requester.rs +++ b/launchdarkly-server-sdk/src/feature_requester.rs @@ -1,6 +1,8 @@ use crate::reqwest::is_http_error_recoverable; +use bytes::Bytes; use futures::future::BoxFuture; -use hyper::Body; +use http_body_util::{BodyExt, Empty}; +use hyper_util::client::legacy::Client as HyperClient; use std::collections::HashMap; use std::sync::Arc; @@ -20,9 +22,12 @@ pub trait FeatureRequester: Send { fn get_all(&mut self) -> BoxFuture, FeatureRequesterError>>; } +type BoxedBody = + http_body_util::combinators::BoxBody>; + pub struct HyperFeatureRequester { - http: Arc>, - url: hyper::Uri, + http: Arc>, + url: http::Uri, sdk_key: String, cache: Option, default_headers: HashMap<&'static str, String>, @@ -30,8 +35,11 @@ pub struct HyperFeatureRequester { impl HyperFeatureRequester { pub fn new( - http: hyper::Client, - url: hyper::Uri, + http: HyperClient< + C, + http_body_util::combinators::BoxBody>, + >, + url: http::Uri, sdk_key: String, default_headers: HashMap<&'static str, String>, ) -> Self { @@ -47,7 +55,7 @@ impl HyperFeatureRequester { impl FeatureRequester for HyperFeatureRequester where - C: hyper::client::connect::Connect + Clone + Send + Sync + 'static, + C: hyper_util::client::legacy::connect::Connect + Clone + Send + Sync + 'static, { fn get_all(&mut self) -> BoxFuture, FeatureRequesterError>> { Box::pin(async { @@ -73,8 +81,16 @@ where request_builder = request_builder.header("If-None-Match", cache.1.clone()); } + // Create empty body for GET request + let empty_body: http_body_util::combinators::BoxBody< + Bytes, + Box, + > = Empty::::new() + .map_err(|e| Box::new(e) as Box) + .boxed(); + let result = http - .request(request_builder.body(Body::empty()).unwrap()) + .request(request_builder.body(empty_body).unwrap()) .await; let response = match result { @@ -82,7 +98,7 @@ where Err(e) => { // It appears this type of error will not be an HTTP error. // It will be a closed connection, aborted write, timeout, etc. - error!("An error occurred while retrieving flag information {}", e,); + error!("An error occurred while retrieving flag information {e}",); return Err(FeatureRequesterError::Temporary); } }; @@ -101,27 +117,27 @@ where .map_or_else(|_| "".into(), |s| s.into()); if response.status().is_success() { - let bytes = hyper::body::to_bytes(response.into_body()) + let body_bytes = response + .into_body() + .collect() .await .map_err(|e| { - error!( - "An error occurred while reading the polling response body: {}", - e - ); + error!("An error occurred while reading the polling response body: {e}"); FeatureRequesterError::Temporary - })?; - let json = serde_json::from_slice::>(bytes.as_ref()); + })? + .to_bytes(); + let json = serde_json::from_slice::>(body_bytes.as_ref()); return match json { Ok(all_data) => { if !etag.is_empty() { - debug!("Caching data for future use with etag: {}", etag); + debug!("Caching data for future use with etag: {etag}"); self.cache = Some(CachedEntry(all_data.clone(), etag)); } Ok(all_data) } Err(e) => { - error!("An error occurred while parsing the json response: {}", e); + error!("An error occurred while parsing the json response: {e}"); Err(FeatureRequesterError::Temporary) } }; @@ -246,9 +262,13 @@ mod tests { } } - fn build_feature_requester(url: String) -> HyperFeatureRequester { - let http = hyper::Client::builder().build(hyper::client::HttpConnector::new()); - let url = hyper::Uri::from_str(&url).expect("Failed parsing the mock server url"); + fn build_feature_requester( + url: String, + ) -> HyperFeatureRequester { + use hyper_util::rt::TokioExecutor; + let connector = hyper_util::client::legacy::connect::HttpConnector::new(); + let http = HyperClient::builder(TokioExecutor::new()).build(connector); + let url = http::Uri::from_str(&url).expect("Failed parsing the mock server url"); HyperFeatureRequester::new( http, diff --git a/launchdarkly-server-sdk/src/feature_requester_builders.rs b/launchdarkly-server-sdk/src/feature_requester_builders.rs index 1d7f1ee..35e86bc 100644 --- a/launchdarkly-server-sdk/src/feature_requester_builders.rs +++ b/launchdarkly-server-sdk/src/feature_requester_builders.rs @@ -1,12 +1,10 @@ use crate::feature_requester::{FeatureRequester, HyperFeatureRequester}; use crate::LAUNCHDARKLY_TAGS_HEADER; -use hyper::client::connect::Connection; -use hyper::service::Service; -use hyper::Uri; +use http::Uri; +use hyper_util::{client::legacy::Client as HyperClient, rt::TokioExecutor}; use std::collections::HashMap; use std::str::FromStr; use thiserror::Error; -use tokio::io::{AsyncRead, AsyncWrite}; /// Error type used to represent failures when building a [FeatureRequesterFactory] instance. #[non_exhaustive] @@ -29,19 +27,29 @@ pub trait FeatureRequesterFactory: Send { pub struct HyperFeatureRequesterBuilder { url: String, sdk_key: String, - http: hyper::Client, + http: HyperClient< + C, + http_body_util::combinators::BoxBody< + bytes::Bytes, + Box, + >, + >, } impl HyperFeatureRequesterBuilder where - C: Service + Clone + Send + Sync + 'static, - C::Response: Connection + AsyncRead + AsyncWrite + Send + Unpin, + C: tower::Service + Clone + Send + Sync + 'static, + C::Response: hyper_util::client::legacy::connect::Connection + + hyper::rt::Read + + hyper::rt::Write + + Send + + Unpin, C::Future: Send + Unpin + 'static, C::Error: Into>, { pub fn new(url: &str, sdk_key: &str, connector: C) -> Self { Self { - http: hyper::Client::builder().build(connector), + http: HyperClient::builder(TokioExecutor::new()).build(connector), url: url.into(), sdk_key: sdk_key.into(), } @@ -50,8 +58,12 @@ where impl FeatureRequesterFactory for HyperFeatureRequesterBuilder where - C: Service + Clone + Send + Sync + 'static, - C::Response: Connection + AsyncRead + AsyncWrite + Send + Unpin, + C: tower::Service + Clone + Send + Sync + 'static, + C::Response: hyper_util::client::legacy::connect::Connection + + hyper::rt::Read + + hyper::rt::Write + + Send + + Unpin, C::Future: Send + Unpin + 'static, C::Error: Into>, { @@ -64,7 +76,7 @@ where default_headers.insert(LAUNCHDARKLY_TAGS_HEADER, tags); } - let url = hyper::Uri::from_str(url.as_str()) + let url = Uri::from_str(url.as_str()) .map_err(|_| BuildError::InvalidConfig("Invalid base url provided".into()))?; Ok(Box::new(HyperFeatureRequester::new( @@ -78,7 +90,7 @@ where #[cfg(test)] mod tests { - use hyper::client::HttpConnector; + use hyper_util::client::legacy::connect::HttpConnector; use super::*; diff --git a/launchdarkly-server-sdk/src/migrations/mod.rs b/launchdarkly-server-sdk/src/migrations/mod.rs index 91f9541..c3dea2c 100644 --- a/launchdarkly-server-sdk/src/migrations/mod.rs +++ b/launchdarkly-server-sdk/src/migrations/mod.rs @@ -79,7 +79,7 @@ impl TryFrom for Stage { "live" => Ok(Stage::Live), "rampdown" => Ok(Stage::Rampdown), "complete" => Ok(Stage::Complete), - _ => Err(format!("Invalid stage: {}", value)), + _ => Err(format!("Invalid stage: {value}")), } } else { Err("Cannot convert non-string value to Stage".to_string()) diff --git a/launchdarkly-server-sdk/src/migrations/tracker.rs b/launchdarkly-server-sdk/src/migrations/tracker.rs index 547d294..d1ec6b1 100644 --- a/launchdarkly-server-sdk/src/migrations/tracker.rs +++ b/launchdarkly-server-sdk/src/migrations/tracker.rs @@ -140,15 +140,13 @@ impl MigrationOpTracker { if self.errors.contains(origin) { return Err(format!( - "provided error for origin {:?} without recording invocation", - origin + "provided error for origin {origin:?} without recording invocation" )); } if self.latencies.contains_key(origin) { return Err(format!( - "provided latency for origin {:?} without recording invocation", - origin + "provided latency for origin {origin:?} without recording invocation" )); } } diff --git a/launchdarkly-server-sdk/src/stores/persistent_store_wrapper.rs b/launchdarkly-server-sdk/src/stores/persistent_store_wrapper.rs index a2831e6..8d304a5 100644 --- a/launchdarkly-server-sdk/src/stores/persistent_store_wrapper.rs +++ b/launchdarkly-server-sdk/src/stores/persistent_store_wrapper.rs @@ -145,14 +145,14 @@ impl Store for PersistentDataStoreWrapper { item.into() } Err(e) => { - warn!("failed to convert serialized item into flag: {}", e); + warn!("failed to convert serialized item into flag: {e}"); None } } } Ok(None) => None, Err(e) => { - warn!("persistent store failed to retrieve flag: {}", e); + warn!("persistent store failed to retrieve flag: {e}"); None } } @@ -173,14 +173,14 @@ impl Store for PersistentDataStoreWrapper { item.into() } Err(e) => { - warn!("failed to convert serialized item into segment: {}", e); + warn!("failed to convert serialized item into segment: {e}"); None } } } Ok(None) => None, Err(e) => { - warn!("persistent store failed to retrieve segment: {}", e); + warn!("persistent store failed to retrieve segment: {e}"); None } } @@ -195,10 +195,7 @@ impl DataStore for PersistentDataStoreWrapper { let serialized_data = AllData::::try_from(all_data.clone()); match serialized_data { - Err(e) => warn!( - "failed to deserialize payload; cannot initialize store {}", - e - ), + Err(e) => warn!("failed to deserialize payload; cannot initialize store {e}"), Ok(data) => { let result = self.store.init(data); @@ -208,7 +205,7 @@ impl DataStore for PersistentDataStoreWrapper { self.cache_items(all_data.into()); } Err(e) => { - error!("failed to init store: {}", e); + error!("failed to init store: {e}"); if self.flags.cache_is_infinite() { debug!("updating non-expiring cache"); self.cache_items(all_data.into()) @@ -249,13 +246,13 @@ impl DataStore for PersistentDataStoreWrapper { HashMap::from_iter(flag_iter) } Err(e) => { - warn!("failed to convert serialized items into flags: {}", e); + warn!("failed to convert serialized items into flags: {e}"); HashMap::new() } } } Err(e) => { - warn!("persistent store failed to retrieve all flags: {}", e); + warn!("persistent store failed to retrieve all flags: {e}"); HashMap::new() } } @@ -267,7 +264,7 @@ impl DataStore for PersistentDataStoreWrapper { PatchTarget::Segment(item) => self.upsert_segment(key, item), PatchTarget::Other(v) => Err(UpdateError::InvalidTarget( "flag or segment".to_string(), - format!("{:?}", v), + format!("{v:?}"), )), } } diff --git a/launchdarkly-server-sdk/src/stores/store.rs b/launchdarkly-server-sdk/src/stores/store.rs index 28d87c2..a4e4d4b 100644 --- a/launchdarkly-server-sdk/src/stores/store.rs +++ b/launchdarkly-server-sdk/src/stores/store.rs @@ -106,7 +106,7 @@ impl DataStore for InMemoryDataStore { } PatchTarget::Other(v) => Err(UpdateError::InvalidTarget( "flag or segment".to_string(), - format!("{:?}", v), + format!("{v:?}"), )), } } From 1a7b6239290de1cee63c3a7fce0c9bacd8963a77 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Tue, 20 Jan 2026 13:27:28 -0500 Subject: [PATCH 03/15] feat: Add HttpTransport trait abstraction (#136) Introduce a generic HttpTransport trait to decouple the SDK from specific HTTP client implementations. This follows the pattern established in eventsource-client. - Add transport.rs with HttpTransport trait and error types - Add transport_hyper.rs with hyper 1.0 implementation - Make hyper an optional dependency via feature flags - Use Bytes for request bodies (supports binary and empty) - Stream response bodies via ByteStream type alias This commit adds the abstraction without changing existing code. --- .github/actions/contract-tests/action.yml | 2 +- .github/workflows/ci.yml | 2 +- Makefile | 2 +- contract-tests/Cargo.toml | 6 +- contract-tests/src/main.rs | 18 +- launchdarkly-server-sdk/Cargo.toml | 19 +- launchdarkly-server-sdk/src/config.rs | 8 +- .../src/data_source_builders.rs | 10 +- .../src/events/processor_builders.rs | 6 +- launchdarkly-server-sdk/src/lib.rs | 12 +- launchdarkly-server-sdk/src/transport.rs | 122 +++++++++ .../src/transport_hyper.rs | 246 ++++++++++++++++++ 12 files changed, 416 insertions(+), 37 deletions(-) create mode 100644 launchdarkly-server-sdk/src/transport.rs create mode 100644 launchdarkly-server-sdk/src/transport_hyper.rs diff --git a/.github/actions/contract-tests/action.yml b/.github/actions/contract-tests/action.yml index a4f9438..ccb165e 100644 --- a/.github/actions/contract-tests/action.yml +++ b/.github/actions/contract-tests/action.yml @@ -17,7 +17,7 @@ runs: - name: Start contract test service shell: bash - run: make start-contract-test-service-bg + run: TLS_FEATURE="${{ inputs.tls_feature }}" make start-contract-test-service-bg - uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1.0.2 with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad2741e..b7d30fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: - name: "Run contract tests with hyper_rustls" uses: ./.github/actions/contract-tests with: - tls_feature: "rustls" + tls_feature: "hyper-rustls" token: ${{ secrets.GITHUB_TOKEN }} - name: "Run contract tests with hyper_tls" diff --git a/Makefile b/Makefile index 3da13d4..307093a 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ TEMP_TEST_OUTPUT=/tmp/contract-test-service.log -TLS_FEATURE ?= rustls +TLS_FEATURE ?= hyper-rustls build-contract-tests: cargo build -p contract-tests --release --no-default-features --features "$(TLS_FEATURE)" diff --git a/contract-tests/Cargo.toml b/contract-tests/Cargo.toml index 2e1cf4a..682347d 100644 --- a/contract-tests/Cargo.toml +++ b/contract-tests/Cargo.toml @@ -23,6 +23,6 @@ reqwest = { version = "0.12.4", features = ["default", "blocking", "json"] } async-mutex = "1.4.0" [features] -default = ["rustls"] -rustls = ["hyper-rustls", "launchdarkly-server-sdk/rustls"] -tls = ["hyper-tls"] +default = ["hyper-rustls"] +hyper-rustls = ["dep:hyper-rustls", "launchdarkly-server-sdk/hyper-rustls"] +tls = ["hyper-tls", "launchdarkly-server-sdk/hyper"] diff --git a/contract-tests/src/main.rs b/contract-tests/src/main.rs index e4b1b36..7d5e0f3 100644 --- a/contract-tests/src/main.rs +++ b/contract-tests/src/main.rs @@ -210,9 +210,9 @@ struct AppState { streaming_https_connector: StreamingHttpsConnector, } -#[cfg(feature = "rustls")] +#[cfg(feature = "hyper-rustls")] type HttpsConnector = hyper_rustls::HttpsConnector; -#[cfg(feature = "rustls")] +#[cfg(feature = "hyper-rustls")] type StreamingHttpsConnector = hyper_util::client::legacy::connect::HttpConnector; #[cfg(feature = "tls")] @@ -224,20 +224,22 @@ type StreamingHttpsConnector = hyper_tls::HttpsConnector; async fn main() -> std::io::Result<()> { env_logger::init(); - #[cfg(not(any(feature = "tls", feature = "rustls")))] + #[cfg(not(any(feature = "tls", feature = "hyper-rustls")))] { - compile_error!("one of the { \"tls\", \"rustls\" } features must be enabled"); + compile_error!("one of the { \"tls\", \"hyper-rustls\" } features must be enabled"); } - #[cfg(all(feature = "tls", feature = "rustls"))] + #[cfg(all(feature = "tls", feature = "hyper-rustls"))] { - compile_error!("only one of the { \"tls\", \"rustls\" } features can be enabled at a time"); + compile_error!( + "only one of the { \"tls\", \"hyper-rustls\" } features can be enabled at a time" + ); } let (tx, rx) = mpsc::channel::<()>(); - #[cfg(feature = "rustls")] + #[cfg(feature = "hyper-rustls")] let streaming_https_connector = hyper_util::client::legacy::connect::HttpConnector::new(); - #[cfg(feature = "rustls")] + #[cfg(feature = "hyper-rustls")] let connector = hyper_rustls::HttpsConnectorBuilder::new() .with_native_roots() .expect("Failed to load native root certificates") diff --git a/launchdarkly-server-sdk/Cargo.toml b/launchdarkly-server-sdk/Cargo.toml index 9e6e2b8..3fa8aaa 100644 --- a/launchdarkly-server-sdk/Cargo.toml +++ b/launchdarkly-server-sdk/Cargo.toml @@ -21,7 +21,7 @@ chrono = "0.4.19" crossbeam-channel = "0.5.1" data-encoding = "2.3.2" # eventsource-client = { version = "0.16.0", default-features = false } -eventsource-client = { git = "https://github.com/launchdarkly/rust-eventsource-client", branch = "feat/hyper-as-feature" } +eventsource-client = { git = "https://github.com/launchdarkly/rust-eventsource-client", default-features = false, branch = "feat/hyper-as-feature" } futures = "0.3.12" lazy_static = "1.4.0" log = "0.4.14" @@ -37,11 +37,11 @@ moka = { version = "0.12.1", features = ["sync"] } uuid = {version = "1.2.2", features = ["v4"] } http = "1.0" bytes = "1.11" -hyper = { version = "1.0", features = ["client", "http1", "http2"] } -hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2", "tokio"] } -http-body-util = { version = "0.1" } +hyper = { version = "1.0", features = ["client", "http1", "http2"], optional = true } +hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2", "tokio"], optional = true } +http-body-util = { version = "0.1", optional = true } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "webpki-roots"], optional = true} -tower = { version = "0.4" } +tower = { version = "0.4", optional = true } rand = "0.9" flate2 = { version = "1.0.35", optional = true } aws-lc-rs = "1.14.1" @@ -59,14 +59,15 @@ reqwest = { version = "0.12.4", features = ["json"] } testing_logger = "0.1.1" [features] -default = ["rustls"] -rustls = ["hyper-rustls/http1", "hyper-rustls/http2", "eventsource-client/hyper-rustls"] +default = ["hyper-rustls"] +hyper = ["dep:hyper", "dep:hyper-util", "dep:http-body-util", "dep:tower", "eventsource-client/hyper"] +hyper-rustls = ["dep:hyper-rustls", "hyper", "eventsource-client/hyper-rustls"] event-compression = ["flate2"] [[example]] name = "print_flags" -required-features = ["rustls"] +required-features = ["hyper-rustls"] [[example]] name = "progress" -required-features = ["rustls"] +required-features = ["hyper-rustls"] diff --git a/launchdarkly-server-sdk/src/config.rs b/launchdarkly-server-sdk/src/config.rs index 846fb98..bb75eff 100644 --- a/launchdarkly-server-sdk/src/config.rs +++ b/launchdarkly-server-sdk/src/config.rs @@ -301,11 +301,11 @@ impl ConfigBuilder { Ok(Box::new(NullDataSourceBuilder::new())) } Some(builder) => Ok(builder), - #[cfg(feature = "rustls")] + #[cfg(feature = "hyper-rustls")] None => Ok(Box::new( StreamingDataSourceBuilder::::new(), )), - #[cfg(not(feature = "rustls"))] + #[cfg(not(feature = "hyper-rustls"))] None => Err(BuildError::InvalidConfig( "data source builder required when rustls is disabled".into(), )), @@ -320,13 +320,13 @@ impl ConfigBuilder { Ok(Box::new(NullEventProcessorBuilder::new())) } Some(builder) => Ok(builder), - #[cfg(feature = "rustls")] + #[cfg(feature = "hyper-rustls")] None => Ok(Box::new(EventProcessorBuilder::< hyper_rustls::HttpsConnector< hyper_util::client::legacy::connect::HttpConnector, >, >::new())), - #[cfg(not(feature = "rustls"))] + #[cfg(not(feature = "hyper-rustls"))] None => Err(BuildError::InvalidConfig( "event processor factory required when rustls is disabled".into(), )), diff --git a/launchdarkly-server-sdk/src/data_source_builders.rs b/launchdarkly-server-sdk/src/data_source_builders.rs index db2f258..ebdd501 100644 --- a/launchdarkly-server-sdk/src/data_source_builders.rs +++ b/launchdarkly-server-sdk/src/data_source_builders.rs @@ -3,7 +3,7 @@ use crate::data_source::{DataSource, NullDataSource, PollingDataSource, Streamin use crate::feature_requester_builders::{FeatureRequesterFactory, HyperFeatureRequesterBuilder}; use eventsource_client as es; use http::Uri; -#[cfg(feature = "rustls")] +#[cfg(feature = "hyper-rustls")] use hyper_rustls::HttpsConnectorBuilder; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -93,7 +93,7 @@ impl DataSourceFactory for StreamingDataSourceBuilder { tags: Option, ) -> Result, BuildError> { let data_source_result = match &self.transport { - #[cfg(feature = "rustls")] + #[cfg(feature = "hyper-rustls")] None => Ok(StreamingDataSource::new( endpoints.streaming_base_url(), sdk_key, @@ -101,7 +101,7 @@ impl DataSourceFactory for StreamingDataSourceBuilder { &tags, es::HyperTransport::new_https(), )), - #[cfg(not(feature = "rustls"))] + #[cfg(not(feature = "hyper-rustls"))] None => Err(BuildError::InvalidConfig( "https connector required when rustls is disabled".into(), )), @@ -259,7 +259,7 @@ where ) -> Result, BuildError> { let feature_requester_builder: Result, BuildError> = match &self.connector { - #[cfg(feature = "rustls")] + #[cfg(feature = "hyper-rustls")] None => { let connector = HttpsConnectorBuilder::new() .with_native_roots() @@ -278,7 +278,7 @@ where connector, ))) } - #[cfg(not(feature = "rustls"))] + #[cfg(not(feature = "hyper-rustls"))] None => Err(BuildError::InvalidConfig( "https connector required when rustls is disabled".into(), )), diff --git a/launchdarkly-server-sdk/src/events/processor_builders.rs b/launchdarkly-server-sdk/src/events/processor_builders.rs index d7ea7be..4f3e49e 100644 --- a/launchdarkly-server-sdk/src/events/processor_builders.rs +++ b/launchdarkly-server-sdk/src/events/processor_builders.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use std::time::Duration; use http::Uri; -#[cfg(feature = "rustls")] +#[cfg(feature = "hyper-rustls")] use hyper_rustls::HttpsConnectorBuilder; use launchdarkly_server_sdk_evaluation::Reference; use thiserror::Error; @@ -123,7 +123,7 @@ where self.compress_events, ))) } else { - #[cfg(feature = "rustls")] + #[cfg(feature = "hyper-rustls")] { let connector = HttpsConnectorBuilder::new() .with_native_roots() @@ -144,7 +144,7 @@ where self.compress_events, ))) } - #[cfg(not(feature = "rustls"))] + #[cfg(not(feature = "hyper-rustls"))] Err(BuildError::InvalidConfig( "https connector is required when rustls is disabled".into(), )) diff --git a/launchdarkly-server-sdk/src/lib.rs b/launchdarkly-server-sdk/src/lib.rs index 06c791f..5847d9d 100644 --- a/launchdarkly-server-sdk/src/lib.rs +++ b/launchdarkly-server-sdk/src/lib.rs @@ -52,6 +52,11 @@ pub use stores::persistent_store_builders::{ pub use stores::store_types::{AllData, DataKind, SerializedItem, StorageItem}; pub use version::version_string; +// Re-export transport types +pub use transport::{HttpTransport, ResponseFuture, TransportError}; +#[cfg(feature = "hyper")] +pub use transport_hyper::HyperTransport; + mod client; mod config; mod data_source; @@ -66,6 +71,9 @@ mod sampler; mod service_endpoints; mod stores; mod test_common; +mod transport; +#[cfg(feature = "hyper")] +mod transport_hyper; mod version; static LAUNCHDARKLY_EVENT_SCHEMA_HEADER: &str = "x-launchdarkly-event-schema"; @@ -78,8 +86,8 @@ lazy_static! { format!("RustServerClient/{}", version_string()); // For cases where a statically empty header value are needed. - pub(crate) static ref EMPTY_HEADER: hyper::header::HeaderValue = - hyper::header::HeaderValue::from_static(""); + pub(crate) static ref EMPTY_HEADER: http::HeaderValue = + http::HeaderValue::from_static(""); } #[cfg(test)] diff --git a/launchdarkly-server-sdk/src/transport.rs b/launchdarkly-server-sdk/src/transport.rs new file mode 100644 index 0000000..92e9900 --- /dev/null +++ b/launchdarkly-server-sdk/src/transport.rs @@ -0,0 +1,122 @@ +//! HTTP transport abstraction for LaunchDarkly SDK +//! +//! This module defines the [`HttpTransport`] trait which allows users to plug in +//! their own HTTP client implementation (hyper, reqwest, or custom). + +use bytes::Bytes; +use futures::Stream; +use std::error::Error as StdError; +use std::fmt; +use std::future::Future; +use std::pin::Pin; + +// Re-export http crate types for convenience +pub use http::{Request, Response}; + +/// A pinned, boxed stream of bytes returned by HTTP transports. +/// +/// This represents the streaming response body from an HTTP request. +pub type ByteStream = Pin> + Send + Sync>>; + +/// A pinned, boxed future for an HTTP response. +/// +/// This represents the future returned by [`HttpTransport::request`]. +pub type ResponseFuture = + Pin, TransportError>> + Send + Sync>>; + +/// Error type for HTTP transport operations. +/// +/// This wraps transport-specific errors (network failures, timeouts, etc.) in a +/// common error type that the SDK can handle uniformly. +#[derive(Debug)] +pub struct TransportError { + inner: Box, +} + +impl TransportError { + /// Create a new transport error from any error type. + pub fn new(err: impl StdError + Send + Sync + 'static) -> Self { + Self { + inner: Box::new(err), + } + } + + /// Get a reference to the inner error. + pub fn inner(&self) -> &(dyn StdError + Send + Sync + 'static) { + &*self.inner + } +} + +impl fmt::Display for TransportError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "transport error: {}", self.inner) + } +} + +impl StdError for TransportError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + Some(&*self.inner) + } +} + +/// Trait for pluggable HTTP transport implementations. +/// +/// Implement this trait to provide HTTP request/response functionality for the +/// SDK. The transport is responsible for: +/// - Establishing HTTP connections (with TLS if needed) +/// - Sending HTTP requests +/// - Returning streaming HTTP responses +/// - Handling timeouts (if desired) +/// +/// The SDK normally uses [`crate::HyperTransport`] as the default implementation, +/// but you can provide your own implementation for custom requirements such as: +/// - Using a different HTTP client library (reqwest, custom, etc.) +/// - Adding request/response logging or metrics +/// - Implementing custom retry logic +/// - Using a proxy or custom TLS configuration +/// +/// # Example +/// +/// ```no_run +/// use launchdarkly_server_sdk::{HttpTransport, ResponseFuture, TransportError}; +/// use bytes::Bytes; +/// use http::{Request, Response}; +/// +/// #[derive(Clone)] +/// struct LoggingTransport { +/// inner: T, +/// } +/// +/// impl HttpTransport for LoggingTransport { +/// fn request(&self, request: Request) -> ResponseFuture { +/// println!("Making request to: {}", request.uri()); +/// self.inner.request(request) +/// } +/// } +/// ``` +pub trait HttpTransport: Clone + Send + Sync + 'static { + /// Execute an HTTP request and return a streaming response. + /// + /// # Arguments + /// + /// * `request` - The HTTP request to execute. The body type is `Bytes` + /// to support both binary content and empty bodies. Use `Bytes::new()` + /// for requests with no body (e.g., GET requests). + /// + /// # Returns + /// + /// A future that resolves to an HTTP response with a streaming body, or a + /// transport error if the request fails. + /// + /// The response includes: + /// - Status code + /// - Response headers + /// - A stream of body bytes + /// + /// # Notes + /// + /// - The transport should NOT follow redirects - the SDK handles this when needed + /// - The transport should NOT retry requests - the SDK handles this + /// - The transport MAY implement timeouts as desired + fn request(&self, request: Request) -> ResponseFuture; +} diff --git a/launchdarkly-server-sdk/src/transport_hyper.rs b/launchdarkly-server-sdk/src/transport_hyper.rs new file mode 100644 index 0000000..d73348e --- /dev/null +++ b/launchdarkly-server-sdk/src/transport_hyper.rs @@ -0,0 +1,246 @@ +//! Hyper v1 transport implementation for LaunchDarkly SDK +//! +//! This module provides a production-ready [`HyperTransport`] implementation that +//! integrates hyper v1 with the LaunchDarkly SDK. + +use crate::transport::{ByteStream, HttpTransport, ResponseFuture, TransportError}; +use bytes::Bytes; +use http::{Request, Response}; +use http_body_util::{combinators::BoxBody, BodyExt, Empty, Full}; +use hyper::body::Incoming; +use hyper_util::client::legacy::Client as HyperClient; +use hyper_util::rt::TokioExecutor; + +/// A transport implementation using hyper v1.x +/// +/// This struct wraps a hyper client and implements the [`HttpTransport`] trait +/// for use with the LaunchDarkly SDK. +/// +/// # Default Configuration +/// +/// By default, `HyperTransport` uses: +/// - HTTP-only connector (no TLS) +/// - Both HTTP/1.1 and HTTP/2 protocol support +/// - No timeout configuration +/// +/// For HTTPS support, use [`HyperTransport::new_https()`] (requires the `rustls` feature) +/// or provide your own connector with [`HyperTransport::new_with_connector()`]. +/// +/// # Example +/// +/// ```ignore +/// use launchdarkly_server_sdk::{HyperTransport, ConfigBuilder, EventProcessorBuilder}; +/// +/// # #[cfg(feature = "hyper-rustls")] +/// # { +/// // Use default HTTPS transport +/// let transport = HyperTransport::new_https(); +/// +/// let config = ConfigBuilder::new("sdk-key") +/// .event_processor(EventProcessorBuilder::new().transport(transport.clone())) +/// .build(); +/// # } +/// ``` +#[derive(Clone)] +pub struct HyperTransport { + client: HyperClient>>, +} + +impl HyperTransport { + /// Create a new HyperTransport with default HTTP connector and no timeouts + /// + /// This creates a basic HTTP-only client that supports both HTTP/1 and HTTP/2. + /// For HTTPS support, use [`HyperTransport::new_https()`] instead. + /// + /// # Example + /// + /// ``` + /// use launchdarkly_server_sdk::HyperTransport; + /// + /// let transport = HyperTransport::new(); + /// ``` + pub fn new() -> Self { + let connector = hyper_util::client::legacy::connect::HttpConnector::new(); + let client = HyperClient::builder(TokioExecutor::new()).build(connector); + Self { client } + } + + /// Create a new HyperTransport with HTTPS support using rustls + /// + /// This creates an HTTPS client that supports both HTTP/1 and HTTP/2 protocols + /// with native certificate verification. + /// + /// This method is only available when the `rustls` feature is enabled. + /// + /// # Example + /// + /// ```no_run + /// # #[cfg(feature = "hyper-rustls")] + /// # { + /// use launchdarkly_server_sdk::HyperTransport; + /// + /// let transport = HyperTransport::new_https(); + /// # } + /// ``` + #[cfg(feature = "hyper-rustls")] + pub fn new_https() -> HyperTransport< + hyper_rustls::HttpsConnector, + > { + use hyper_rustls::HttpsConnectorBuilder; + + let connector = HttpsConnectorBuilder::new() + .with_webpki_roots() + .https_or_http() + .enable_http1() + .enable_http2() + .build(); + + let client = HyperClient::builder(TokioExecutor::new()).build(connector); + HyperTransport { client } + } +} + +impl HyperTransport { + /// Create a new HyperTransport with a custom connector + /// + /// This allows you to provide your own connector implementation, which is useful for: + /// - Custom TLS configuration + /// - Proxy support + /// - Connection pooling customization + /// - Custom DNS resolution + /// + /// # Example + /// + /// ```no_run + /// use launchdarkly_server_sdk::HyperTransport; + /// use hyper_util::client::legacy::connect::HttpConnector; + /// + /// let mut connector = HttpConnector::new(); + /// connector.set_nodelay(true); + /// + /// let transport = HyperTransport::new_with_connector(connector); + /// ``` + pub fn new_with_connector(connector: C) -> Self + where + C: hyper_util::client::legacy::connect::Connect + Clone + Send + Sync + 'static, + { + let client = HyperClient::builder(TokioExecutor::new()).build(connector); + Self { client } + } +} + +impl Default for HyperTransport { + fn default() -> Self { + Self::new() + } +} + +impl HttpTransport for HyperTransport +where + C: hyper_util::client::legacy::connect::Connect + Clone + Send + Sync + 'static, +{ + fn request(&self, request: Request) -> ResponseFuture { + let (parts, body) = request.into_parts(); + + // Convert Bytes to BoxBody for hyper + let boxed_body: BoxBody> = + if body.is_empty() { + // Use Empty for requests with no body (e.g., GET requests) + Empty::::new() + .map_err(|e| Box::new(e) as Box) + .boxed() + } else { + // Use Full for requests with a body + Full::new(body) + .map_err(|e| Box::new(e) as Box) + .boxed() + }; + + let hyper_req = hyper::Request::from_parts(parts, boxed_body); + let client = self.client.clone(); + + Box::pin(async move { + // Make the request + let resp = client + .request(hyper_req) + .await + .map_err(TransportError::new)?; + + let (parts, body) = resp.into_parts(); + + // Convert hyper's Incoming body to ByteStream + let byte_stream: ByteStream = Box::pin(body_to_stream(body)); + + Ok(Response::from_parts(parts, byte_stream)) + }) + } +} + +/// Convert hyper's Incoming body to a Stream of Bytes +fn body_to_stream( + body: Incoming, +) -> impl futures::Stream> + Send { + futures::stream::unfold(body, |mut body| async move { + match body.frame().await { + Some(Ok(frame)) => { + if let Ok(data) = frame.into_data() { + // Successfully got data frame + Some((Ok(data), body)) + } else { + // Skip non-data frames (trailers, etc.) + Some(( + Err(TransportError::new(std::io::Error::other("non-data frame"))), + body, + )) + } + } + Some(Err(e)) => { + // Error reading frame + Some((Err(TransportError::new(e)), body)) + } + None => { + // End of stream + None + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hyper_transport_new() { + let transport = HyperTransport::new(); + // If we can create it without panic, the test passes + // This verifies the default HTTP connector is set up correctly + drop(transport); + } + + #[test] + fn test_hyper_transport_default() { + let transport = HyperTransport::default(); + // Verify Default trait implementation + drop(transport); + } + + #[cfg(feature = "hyper-rustls")] + #[test] + fn test_hyper_transport_new_https() { + let transport = HyperTransport::new_https(); + // If we can create it without panic, the test passes + // This verifies the HTTPS connector with rustls is set up correctly + drop(transport); + } + + #[test] + fn test_new_with_connector() { + use hyper_util::client::legacy::connect::HttpConnector; + + let connector = HttpConnector::new(); + let transport = HyperTransport::new_with_connector(connector); + // Verify we can build with a custom connector + drop(transport); + } +} From e9846922d204225f60ce5672744d8f1a3abf88b6 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Tue, 20 Jan 2026 13:38:43 -0500 Subject: [PATCH 04/15] feat!: Migrate events to HttpTransport (#137) --- contract-tests/src/client_entity.rs | 4 +- launchdarkly-server-sdk/src/client.rs | 4 +- launchdarkly-server-sdk/src/config.rs | 24 +++--- .../src/events/processor_builders.rs | 82 +++++++------------ launchdarkly-server-sdk/src/events/sender.rs | 62 ++++---------- 5 files changed, 63 insertions(+), 113 deletions(-) diff --git a/contract-tests/src/client_entity.rs b/contract-tests/src/client_entity.rs index f4931e7..dc9d399 100644 --- a/contract-tests/src/client_entity.rs +++ b/contract-tests/src/client_entity.rs @@ -131,7 +131,9 @@ impl ClientEntity { if let Some(attributes) = events.global_private_attributes { processor_builder.private_attributes(attributes); } - processor_builder.https_connector(connector.clone()); + let transport = + launchdarkly_server_sdk::HyperTransport::new_with_connector(connector.clone()); + processor_builder.transport(transport); processor_builder.omit_anonymous_contexts(events.omit_anonymous_contexts); config_builder.event_processor(&processor_builder) diff --git a/launchdarkly-server-sdk/src/client.rs b/launchdarkly-server-sdk/src/client.rs index a157c65..f3ece21 100644 --- a/launchdarkly-server-sdk/src/client.rs +++ b/launchdarkly-server-sdk/src/client.rs @@ -828,7 +828,6 @@ mod tests { use crossbeam_channel::Receiver; use eval::{ContextBuilder, MultiContextBuilder}; use futures::FutureExt; - use hyper_util::client::legacy::connect::HttpConnector; use launchdarkly_server_sdk_evaluation::{Flag, Reason, Segment}; use maplit::hashmap; use std::collections::HashMap; @@ -2600,7 +2599,8 @@ mod tests { .daemon_mode(daemon_mode) .data_source(MockDataSourceBuilder::new().data_source(updates)) .event_processor( - EventProcessorBuilder::::new().event_sender(Arc::new(event_sender)), + EventProcessorBuilder::::new() + .event_sender(Arc::new(event_sender)), ) .build() .expect("config should build"); diff --git a/launchdarkly-server-sdk/src/config.rs b/launchdarkly-server-sdk/src/config.rs index bb75eff..21f4411 100644 --- a/launchdarkly-server-sdk/src/config.rs +++ b/launchdarkly-server-sdk/src/config.rs @@ -302,12 +302,15 @@ impl ConfigBuilder { } Some(builder) => Ok(builder), #[cfg(feature = "hyper-rustls")] - None => Ok(Box::new( - StreamingDataSourceBuilder::::new(), - )), + None => { + let transport = es::HyperTransport::new_https(); + let mut builder = StreamingDataSourceBuilder::new(); + builder.transport(transport); + Ok(Box::new(builder)) + } #[cfg(not(feature = "hyper-rustls"))] None => Err(BuildError::InvalidConfig( - "data source builder required when rustls is disabled".into(), + "data source builder required when hyper-rustls feature is disabled".into(), )), }; let data_source_builder = data_source_builder_result?; @@ -321,14 +324,15 @@ impl ConfigBuilder { } Some(builder) => Ok(builder), #[cfg(feature = "hyper-rustls")] - None => Ok(Box::new(EventProcessorBuilder::< - hyper_rustls::HttpsConnector< - hyper_util::client::legacy::connect::HttpConnector, - >, - >::new())), + None => { + let transport = crate::HyperTransport::new_https(); + let mut builder = EventProcessorBuilder::new(); + builder.transport(transport); + Ok(Box::new(builder)) + } #[cfg(not(feature = "hyper-rustls"))] None => Err(BuildError::InvalidConfig( - "event processor factory required when rustls is disabled".into(), + "event processor factory required when hyper-rustls feature is disabled".into(), )), }; let event_processor_builder = event_processor_builder_result?; diff --git a/launchdarkly-server-sdk/src/events/processor_builders.rs b/launchdarkly-server-sdk/src/events/processor_builders.rs index 4f3e49e..5aa1cf4 100644 --- a/launchdarkly-server-sdk/src/events/processor_builders.rs +++ b/launchdarkly-server-sdk/src/events/processor_builders.rs @@ -5,12 +5,11 @@ use std::sync::Arc; use std::time::Duration; use http::Uri; -#[cfg(feature = "hyper-rustls")] -use hyper_rustls::HttpsConnectorBuilder; use launchdarkly_server_sdk_evaluation::Reference; use thiserror::Error; -use crate::events::sender::HyperEventSender; +use crate::events::sender::HttpEventSender; +use crate::transport::HttpTransport; use crate::{service_endpoints, LAUNCHDARKLY_TAGS_HEADER}; use super::processor::{ @@ -61,17 +60,15 @@ pub trait EventProcessorFactory { /// /// Adjust the flush interval /// ``` -/// # use launchdarkly_server_sdk::{EventProcessorBuilder, ConfigBuilder}; -/// # use hyper_rustls::HttpsConnector; -/// # use hyper_util::client::legacy::connect::HttpConnector; +/// # use launchdarkly_server_sdk::{EventProcessorBuilder, ConfigBuilder, HyperTransport}; /// # use std::time::Duration; /// # fn main() { -/// ConfigBuilder::new("sdk-key").event_processor(EventProcessorBuilder::>::new() +/// ConfigBuilder::new("sdk-key").event_processor(EventProcessorBuilder::::new() /// .flush_interval(Duration::from_secs(10))); /// # } /// ``` #[derive(Clone)] -pub struct EventProcessorBuilder { +pub struct EventProcessorBuilder { capacity: usize, flush_interval: Duration, context_keys_capacity: NonZeroUsize, @@ -79,23 +76,13 @@ pub struct EventProcessorBuilder { event_sender: Option>, all_attributes_private: bool, private_attributes: HashSet, - connector: Option, + transport: Option, omit_anonymous_contexts: bool, compress_events: bool, // diagnostic_recording_interval: Duration } -impl EventProcessorFactory for EventProcessorBuilder -where - C: tower::Service + Clone + Send + Sync + 'static, - C::Response: hyper_util::client::legacy::connect::Connection - + hyper::rt::Read - + hyper::rt::Write - + Send - + Unpin, - C::Future: Send + Unpin + 'static, - C::Error: Into>, -{ +impl EventProcessorFactory for EventProcessorBuilder { fn build( &self, endpoints: &service_endpoints::ServiceEndpoints, @@ -114,10 +101,10 @@ where // NOTE: This would only be possible under unit testing conditions. if let Some(event_sender) = &self.event_sender { Ok(event_sender.clone()) - } else if let Some(connector) = &self.connector { - Ok(Arc::new(HyperEventSender::new( - connector.clone(), - hyper::Uri::from_str(url_string.as_str()).unwrap(), + } else if let Some(transport) = &self.transport { + Ok(Arc::new(HttpEventSender::new( + transport.clone(), + Uri::from_str(url_string.as_str()).unwrap(), sdk_key, default_headers, self.compress_events, @@ -125,19 +112,9 @@ where } else { #[cfg(feature = "hyper-rustls")] { - let connector = HttpsConnectorBuilder::new() - .with_native_roots() - .unwrap_or_else(|_| { - log::debug!("Falling back to webpki roots for event HTTPS connector"); - HttpsConnectorBuilder::new().with_webpki_roots() - }) - .https_or_http() - .enable_http1() - .enable_http2() - .build(); - - Ok(Arc::new(HyperEventSender::new( - connector, + let transport = crate::HyperTransport::new_https(); + Ok(Arc::new(HttpEventSender::new( + transport, Uri::from_str(url_string.as_str()).unwrap(), sdk_key, default_headers, @@ -146,7 +123,7 @@ where } #[cfg(not(feature = "hyper-rustls"))] Err(BuildError::InvalidConfig( - "https connector is required when rustls is disabled".into(), + "transport is required when hyper-rustls feature is disabled".into(), )) }; let event_sender = event_sender_result?; @@ -173,7 +150,7 @@ where } } -impl EventProcessorBuilder { +impl EventProcessorBuilder { /// Create a new [EventProcessorBuilder] with all default values. pub fn new() -> Self { Self { @@ -186,7 +163,7 @@ impl EventProcessorBuilder { all_attributes_private: false, private_attributes: HashSet::new(), omit_anonymous_contexts: false, - connector: None, + transport: None, compress_events: false, } } @@ -250,12 +227,12 @@ impl EventProcessorBuilder { self } - /// Sets the connector for the event sender to use. This allows for re-use of a connector + /// Sets the transport for the event sender to use. This allows for re-use of a transport /// between multiple client instances. This is especially useful for the `sdk-test-harness` /// where many client instances are created throughout the test and reading the native /// certificates is a substantial portion of the runtime. - pub fn https_connector(&mut self, connector: C) -> &mut Self { - self.connector = Some(connector); + pub fn transport(&mut self, transport: T) -> &mut Self { + self.transport = Some(transport); self } @@ -288,7 +265,7 @@ impl EventProcessorBuilder { } } -impl Default for EventProcessorBuilder { +impl Default for EventProcessorBuilder { fn default() -> Self { Self::new() } @@ -329,7 +306,6 @@ impl Default for NullEventProcessorBuilder { #[cfg(test)] mod tests { - use hyper_util::client::legacy::connect::HttpConnector; use launchdarkly_server_sdk_evaluation::ContextBuilder; use maplit::hashset; use mockito::Matcher; @@ -341,28 +317,28 @@ mod tests { #[test] fn default_builder_has_correct_defaults() { - let builder = EventProcessorBuilder::::new(); + let builder = EventProcessorBuilder::::new(); assert_eq!(builder.capacity, DEFAULT_EVENT_CAPACITY); assert_eq!(builder.flush_interval, DEFAULT_FLUSH_POLL_INTERVAL); } #[test] fn capacity_can_be_adjusted() { - let mut builder = EventProcessorBuilder::::new(); + let mut builder = EventProcessorBuilder::::new(); builder.capacity(1234); assert_eq!(builder.capacity, 1234); } #[test] fn flush_interval_can_be_adjusted() { - let mut builder = EventProcessorBuilder::::new(); + let mut builder = EventProcessorBuilder::::new(); builder.flush_interval(Duration::from_secs(1234)); assert_eq!(builder.flush_interval, Duration::from_secs(1234)); } #[test] fn context_keys_capacity_can_be_adjusted() { - let mut builder = EventProcessorBuilder::::new(); + let mut builder = EventProcessorBuilder::::new(); let cap = NonZeroUsize::new(1234).expect("1234 > 0"); builder.context_keys_capacity(cap); assert_eq!(builder.context_keys_capacity, cap); @@ -370,7 +346,7 @@ mod tests { #[test] fn context_keys_flush_interval_can_be_adjusted() { - let mut builder = EventProcessorBuilder::::new(); + let mut builder = EventProcessorBuilder::::new(); builder.context_keys_flush_interval(Duration::from_secs(1000)); assert_eq!( builder.context_keys_flush_interval, @@ -380,7 +356,7 @@ mod tests { #[test] fn all_attribute_private_can_be_adjusted() { - let mut builder = EventProcessorBuilder::::new(); + let mut builder = EventProcessorBuilder::::new(); assert!(!builder.all_attributes_private); builder.all_attributes_private(true); @@ -389,7 +365,7 @@ mod tests { #[test] fn attribte_names_can_be_adjusted() { - let mut builder = EventProcessorBuilder::::new(); + let mut builder = EventProcessorBuilder::::new(); assert!(builder.private_attributes.is_empty()); builder.private_attributes(hashset!["name"]); @@ -414,7 +390,7 @@ mod tests { .build() .expect("Service endpoints failed to be created"); - let builder = EventProcessorBuilder::::new(); + let builder = EventProcessorBuilder::::new(); let processor = builder .build(&service_endpoints, "sdk-key", tag) .expect("Processor failed to build"); diff --git a/launchdarkly-server-sdk/src/events/sender.rs b/launchdarkly-server-sdk/src/events/sender.rs index 4a4fbd8..8594c03 100644 --- a/launchdarkly-server-sdk/src/events/sender.rs +++ b/launchdarkly-server-sdk/src/events/sender.rs @@ -1,5 +1,5 @@ use crate::{ - reqwest::is_http_error_recoverable, LAUNCHDARKLY_EVENT_SCHEMA_HEADER, + reqwest::is_http_error_recoverable, transport::HttpTransport, LAUNCHDARKLY_EVENT_SCHEMA_HEADER, LAUNCHDARKLY_PAYLOAD_ID_HEADER, }; use chrono::DateTime; @@ -15,8 +15,6 @@ use std::io::Write; use bytes::Bytes; use futures::future::BoxFuture; -use http_body_util::{BodyExt, Full}; -use hyper_util::{client::legacy::Client as HyperClient, rt::TokioExecutor}; use tokio::time::{sleep, Duration}; use uuid::Uuid; @@ -37,13 +35,10 @@ pub trait EventSender: Send + Sync { } #[derive(Clone)] -pub struct HyperEventSender { +pub struct HttpEventSender { url: http::Uri, sdk_key: String, - http: HyperClient< - C, - http_body_util::combinators::BoxBody>, - >, + transport: T, default_headers: HashMap<&'static str, String>, // used with event-compression feature @@ -51,19 +46,9 @@ pub struct HyperEventSender { compress_events: bool, } -impl HyperEventSender -where - C: tower::Service + Clone + Send + Sync + 'static, - C::Response: hyper_util::client::legacy::connect::Connection - + hyper::rt::Read - + hyper::rt::Write - + Send - + Unpin, - C::Future: Send + Unpin + 'static, - C::Error: Into>, -{ +impl HttpEventSender { pub fn new( - connector: C, + transport: T, url: http::Uri, sdk_key: &str, default_headers: HashMap<&'static str, String>, @@ -72,7 +57,7 @@ where Self { url, sdk_key: sdk_key.to_owned(), - http: HyperClient::builder(TokioExecutor::new()).build(connector), + transport, default_headers, compress_events, } @@ -94,17 +79,7 @@ where } } -impl EventSender for HyperEventSender -where - C: tower::Service + Clone + Send + Sync + 'static, - C::Response: hyper_util::client::legacy::connect::Connection - + hyper::rt::Read - + hyper::rt::Write - + Send - + Unpin, - C::Future: Send + Unpin + 'static, - C::Error: Into>, -{ +impl EventSender for HttpEventSender { fn send_event_data( &self, events: Vec, @@ -149,7 +124,7 @@ where sleep(Duration::from_secs(1)).await; } - let mut request_builder = hyper::Request::builder() + let mut request_builder = http::Request::builder() .uri(self.url.clone()) .method("POST") .header("Content-Type", "application/json") @@ -166,17 +141,11 @@ where request_builder.header(*default_header.0, default_header.1.as_str()); } - // Convert Vec to BoxBody for hyper 1.0 + // Create request with Bytes body for transport let body_bytes = Bytes::from(payload.clone()); - let boxed_body: http_body_util::combinators::BoxBody< - Bytes, - Box, - > = Full::new(body_bytes) - .map_err(|e| Box::new(e) as Box) - .boxed(); - let request = request_builder.body(boxed_body); + let request = request_builder.body(body_bytes).unwrap(); - let result = self.http.request(request.unwrap()).await; + let result = self.transport.request(request).await; let response = match result { Ok(response) => response, @@ -371,14 +340,13 @@ mod tests { assert_eq!(sender_result.time_from_server, 1234567890000); } - fn build_event_sender( - url: String, - ) -> HyperEventSender { + fn build_event_sender(url: String) -> HttpEventSender { let url = format!("{}/bulk", &url); let url = http::Uri::from_str(&url).expect("Failed parsing the mock server url"); - HyperEventSender::new( - hyper_util::client::legacy::connect::HttpConnector::new(), + let transport = crate::HyperTransport::new(); + HttpEventSender::new( + transport, url, "sdk-key", HashMap::<&str, String>::new(), From 92630fba332654441bdd07b38b676cf0b9916d77 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Tue, 20 Jan 2026 13:48:56 -0500 Subject: [PATCH 05/15] refactor: Migrate feature requester to HttpTransport (#138) Convert feature requester and polling data source to use the generic HttpTransport trait instead of hyper Client directly. - Replace HyperFeatureRequester with HttpFeatureRequester - Replace HyperFeatureRequesterBuilder with HttpFeatureRequesterBuilder - Update PollingDataSourceBuilder to accept transport - Stream and collect response body for JSON parsing - Remove hyper-specific imports and trait bounds - Update test helpers to use HyperTransport BREAKING CHANGE: PollingDataSourceBuilder generic parameter changed from connector (C) to transport (T: HttpTransport). --- contract-tests/src/client_entity.rs | 4 +- launchdarkly-server-sdk/src/data_source.rs | 7 +- .../src/data_source_builders.rs | 74 ++++++------------- .../src/feature_requester.rs | 70 +++++++----------- .../src/feature_requester_builders.rs | 53 ++++--------- 5 files changed, 68 insertions(+), 140 deletions(-) diff --git a/contract-tests/src/client_entity.rs b/contract-tests/src/client_entity.rs index dc9d399..9c16166 100644 --- a/contract-tests/src/client_entity.rs +++ b/contract-tests/src/client_entity.rs @@ -96,7 +96,9 @@ impl ClientEntity { if let Some(delay) = polling.poll_interval_ms { polling_builder.poll_interval(Duration::from_millis(delay)); } - polling_builder.https_connector(connector.clone()); + let transport = + launchdarkly_server_sdk::HyperTransport::new_with_connector(connector.clone()); + polling_builder.transport(transport); config_builder = config_builder.data_source(&polling_builder); } else { diff --git a/launchdarkly-server-sdk/src/data_source.rs b/launchdarkly-server-sdk/src/data_source.rs index 022abcc..4c75406 100644 --- a/launchdarkly-server-sdk/src/data_source.rs +++ b/launchdarkly-server-sdk/src/data_source.rs @@ -366,14 +366,13 @@ mod tests { time::Duration, }; - use hyper_util::client::legacy::connect::HttpConnector; use mockito::Matcher; use parking_lot::RwLock; use test_case::test_case; use tokio::sync::broadcast; use super::{DataSource, PollingDataSource, StreamingDataSource}; - use crate::feature_requester_builders::HyperFeatureRequesterBuilder; + use crate::feature_requester_builders::HttpFeatureRequesterBuilder; use crate::{stores::store::InMemoryDataStore, LAUNCHDARKLY_TAGS_HEADER}; use eventsource_client as es; @@ -453,8 +452,8 @@ mod tests { let (shutdown_tx, _) = broadcast::channel::<()>(1); let initialized = Arc::new(AtomicBool::new(false)); - let hyper_builder = - HyperFeatureRequesterBuilder::new(&server.url(), "sdk-key", HttpConnector::new()); + let transport = crate::HyperTransport::new(); + let hyper_builder = HttpFeatureRequesterBuilder::new(&server.url(), "sdk-key", transport); let polling = PollingDataSource::new( Arc::new(Mutex::new(Box::new(hyper_builder))), diff --git a/launchdarkly-server-sdk/src/data_source_builders.rs b/launchdarkly-server-sdk/src/data_source_builders.rs index ebdd501..39ed7d7 100644 --- a/launchdarkly-server-sdk/src/data_source_builders.rs +++ b/launchdarkly-server-sdk/src/data_source_builders.rs @@ -1,10 +1,8 @@ use super::service_endpoints; use crate::data_source::{DataSource, NullDataSource, PollingDataSource, StreamingDataSource}; -use crate::feature_requester_builders::{FeatureRequesterFactory, HyperFeatureRequesterBuilder}; +use crate::feature_requester_builders::{FeatureRequesterFactory, HttpFeatureRequesterBuilder}; +use crate::transport::HttpTransport; use eventsource_client as es; -use http::Uri; -#[cfg(feature = "hyper-rustls")] -use hyper_rustls::HttpsConnectorBuilder; use std::sync::{Arc, Mutex}; use std::time::Duration; use thiserror::Error; @@ -173,19 +171,17 @@ impl Default for NullDataSourceBuilder { /// /// Adjust the initial reconnect delay. /// ``` -/// # use launchdarkly_server_sdk::{PollingDataSourceBuilder, ConfigBuilder}; -/// # use hyper_rustls::HttpsConnector; -/// # use hyper_util::client::legacy::connect::HttpConnector; +/// # use launchdarkly_server_sdk::{PollingDataSourceBuilder, ConfigBuilder, HyperTransport}; /// # use std::time::Duration; /// # fn main() { -/// ConfigBuilder::new("sdk-key").data_source(PollingDataSourceBuilder::>::new() +/// ConfigBuilder::new("sdk-key").data_source(PollingDataSourceBuilder::::new() /// .poll_interval(Duration::from_secs(60))); /// # } /// ``` #[derive(Clone)] -pub struct PollingDataSourceBuilder { +pub struct PollingDataSourceBuilder { poll_interval: Duration, - connector: Option, + transport: Option, } /// Contains methods for configuring the polling data source. @@ -203,21 +199,19 @@ pub struct PollingDataSourceBuilder { /// /// Adjust the poll interval. /// ``` -/// # use launchdarkly_server_sdk::{PollingDataSourceBuilder, ConfigBuilder}; +/// # use launchdarkly_server_sdk::{PollingDataSourceBuilder, ConfigBuilder, HyperTransport}; /// # use std::time::Duration; -/// # use hyper_rustls::HttpsConnector; -/// # use hyper_util::client::legacy::connect::HttpConnector; /// # fn main() { -/// ConfigBuilder::new("sdk-key").data_source(PollingDataSourceBuilder::>::new() +/// ConfigBuilder::new("sdk-key").data_source(PollingDataSourceBuilder::::new() /// .poll_interval(Duration::from_secs(60))); /// # } /// ``` -impl PollingDataSourceBuilder { +impl PollingDataSourceBuilder { /// Create a new instance of the [PollingDataSourceBuilder] with default values. pub fn new() -> Self { Self { poll_interval: MINIMUM_POLL_INTERVAL, - connector: None, + transport: None, } } @@ -230,27 +224,17 @@ impl PollingDataSourceBuilder { self } - /// Sets the connector for the polling client to use. This allows for re-use of a connector + /// Sets the transport for the polling client to use. This allows for re-use of a transport /// between multiple client instances. This is especially useful for the `sdk-test-harness` /// where many client instances are created throughout the test and reading the native /// certificates is a substantial portion of the runtime. - pub fn https_connector(&mut self, connector: C) -> &mut Self { - self.connector = Some(connector); + pub fn transport(&mut self, transport: T) -> &mut Self { + self.transport = Some(transport); self } } -impl DataSourceFactory for PollingDataSourceBuilder -where - C: tower::Service + Clone + Send + Sync + 'static, - C::Response: hyper_util::client::legacy::connect::Connection - + hyper::rt::Read - + hyper::rt::Write - + Send - + Unpin, - C::Future: Send + Unpin + 'static, - C::Error: Into>, -{ +impl DataSourceFactory for PollingDataSourceBuilder { fn build( &self, endpoints: &service_endpoints::ServiceEndpoints, @@ -258,34 +242,25 @@ where tags: Option, ) -> Result, BuildError> { let feature_requester_builder: Result, BuildError> = - match &self.connector { + match &self.transport { #[cfg(feature = "hyper-rustls")] None => { - let connector = HttpsConnectorBuilder::new() - .with_native_roots() - .unwrap_or_else(|_| { - log::debug!("Falling back to webpki roots for polling HTTPS connector"); - HttpsConnectorBuilder::new().with_webpki_roots() - }) - .https_or_http() - .enable_http1() - .enable_http2() - .build(); - - Ok(Box::new(HyperFeatureRequesterBuilder::new( + let transport = crate::HyperTransport::new_https(); + + Ok(Box::new(HttpFeatureRequesterBuilder::new( endpoints.polling_base_url(), sdk_key, - connector, + transport, ))) } #[cfg(not(feature = "hyper-rustls"))] None => Err(BuildError::InvalidConfig( - "https connector required when rustls is disabled".into(), + "transport is required when hyper-rustls feature is disabled".into(), )), - Some(connector) => Ok(Box::new(HyperFeatureRequesterBuilder::new( + Some(transport) => Ok(Box::new(HttpFeatureRequesterBuilder::new( endpoints.polling_base_url(), sdk_key, - connector.clone(), + transport.clone(), ))), }; @@ -302,7 +277,7 @@ where } } -impl Default for PollingDataSourceBuilder { +impl Default for PollingDataSourceBuilder { fn default() -> Self { PollingDataSourceBuilder::new() } @@ -349,7 +324,6 @@ impl DataSourceFactory for MockDataSourceBuilder { #[cfg(test)] mod tests { use eventsource_client::{HyperTransport, ResponseFuture}; - use hyper_util::client::legacy::connect::HttpConnector; use super::*; @@ -392,7 +366,7 @@ mod tests { #[test] fn default_polling_builder_has_correct_defaults() { - let builder = PollingDataSourceBuilder::::new(); + let builder = PollingDataSourceBuilder::::new(); assert_eq!(builder.poll_interval, MINIMUM_POLL_INTERVAL,); } diff --git a/launchdarkly-server-sdk/src/feature_requester.rs b/launchdarkly-server-sdk/src/feature_requester.rs index 41bb197..4e60032 100644 --- a/launchdarkly-server-sdk/src/feature_requester.rs +++ b/launchdarkly-server-sdk/src/feature_requester.rs @@ -1,10 +1,9 @@ use crate::reqwest::is_http_error_recoverable; +use crate::transport::HttpTransport; use bytes::Bytes; use futures::future::BoxFuture; -use http_body_util::{BodyExt, Empty}; -use hyper_util::client::legacy::Client as HyperClient; +use futures::stream::StreamExt; use std::collections::HashMap; -use std::sync::Arc; use super::stores::store_types::AllData; use launchdarkly_server_sdk_evaluation::{Flag, Segment}; @@ -22,29 +21,23 @@ pub trait FeatureRequester: Send { fn get_all(&mut self) -> BoxFuture, FeatureRequesterError>>; } -type BoxedBody = - http_body_util::combinators::BoxBody>; - -pub struct HyperFeatureRequester { - http: Arc>, +pub struct HttpFeatureRequester { + transport: T, url: http::Uri, sdk_key: String, cache: Option, default_headers: HashMap<&'static str, String>, } -impl HyperFeatureRequester { +impl HttpFeatureRequester { pub fn new( - http: HyperClient< - C, - http_body_util::combinators::BoxBody>, - >, + transport: T, url: http::Uri, sdk_key: String, default_headers: HashMap<&'static str, String>, ) -> Self { Self { - http: Arc::new(http), + transport, url, sdk_key, cache: None, @@ -53,19 +46,16 @@ impl HyperFeatureRequester { } } -impl FeatureRequester for HyperFeatureRequester -where - C: hyper_util::client::legacy::connect::Connect + Clone + Send + Sync + 'static, -{ +impl FeatureRequester for HttpFeatureRequester { fn get_all(&mut self) -> BoxFuture, FeatureRequesterError>> { Box::pin(async { let uri = self.url.clone(); let key = self.sdk_key.clone(); - let http = self.http.clone(); + let transport = self.transport.clone(); let cache = self.cache.clone(); - let mut request_builder = hyper::http::Request::builder() + let mut request_builder = http::Request::builder() .uri(uri) .method("GET") .header("Content-Type", "application/json") @@ -82,16 +72,9 @@ where } // Create empty body for GET request - let empty_body: http_body_util::combinators::BoxBody< - Bytes, - Box, - > = Empty::::new() - .map_err(|e| Box::new(e) as Box) - .boxed(); + let request = request_builder.body(Bytes::new()).unwrap(); - let result = http - .request(request_builder.body(empty_body).unwrap()) - .await; + let result = transport.request(request).await; let response = match result { Ok(response) => response, @@ -117,16 +100,17 @@ where .map_or_else(|_| "".into(), |s| s.into()); if response.status().is_success() { - let body_bytes = response - .into_body() - .collect() - .await - .map_err(|e| { + // Collect streaming body + let mut body_bytes = Vec::new(); + let mut stream = response.into_body(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| { error!("An error occurred while reading the polling response body: {e}"); FeatureRequesterError::Temporary - })? - .to_bytes(); - let json = serde_json::from_slice::>(body_bytes.as_ref()); + })?; + body_bytes.extend_from_slice(&chunk); + } + let json = serde_json::from_slice::>(&body_bytes); return match json { Ok(all_data) => { @@ -262,16 +246,12 @@ mod tests { } } - fn build_feature_requester( - url: String, - ) -> HyperFeatureRequester { - use hyper_util::rt::TokioExecutor; - let connector = hyper_util::client::legacy::connect::HttpConnector::new(); - let http = HyperClient::builder(TokioExecutor::new()).build(connector); + fn build_feature_requester(url: String) -> HttpFeatureRequester { let url = http::Uri::from_str(&url).expect("Failed parsing the mock server url"); + let transport = crate::HyperTransport::new(); - HyperFeatureRequester::new( - http, + HttpFeatureRequester::new( + transport, url, "sdk-key".to_string(), HashMap::<&str, String>::new(), diff --git a/launchdarkly-server-sdk/src/feature_requester_builders.rs b/launchdarkly-server-sdk/src/feature_requester_builders.rs index 35e86bc..448fbd0 100644 --- a/launchdarkly-server-sdk/src/feature_requester_builders.rs +++ b/launchdarkly-server-sdk/src/feature_requester_builders.rs @@ -1,7 +1,7 @@ -use crate::feature_requester::{FeatureRequester, HyperFeatureRequester}; +use crate::feature_requester::{FeatureRequester, HttpFeatureRequester}; +use crate::transport::HttpTransport; use crate::LAUNCHDARKLY_TAGS_HEADER; use http::Uri; -use hyper_util::{client::legacy::Client as HyperClient, rt::TokioExecutor}; use std::collections::HashMap; use std::str::FromStr; use thiserror::Error; @@ -24,49 +24,23 @@ pub trait FeatureRequesterFactory: Send { fn build(&self, tags: Option) -> Result, BuildError>; } -pub struct HyperFeatureRequesterBuilder { +pub struct HttpFeatureRequesterBuilder { url: String, sdk_key: String, - http: HyperClient< - C, - http_body_util::combinators::BoxBody< - bytes::Bytes, - Box, - >, - >, + transport: T, } -impl HyperFeatureRequesterBuilder -where - C: tower::Service + Clone + Send + Sync + 'static, - C::Response: hyper_util::client::legacy::connect::Connection - + hyper::rt::Read - + hyper::rt::Write - + Send - + Unpin, - C::Future: Send + Unpin + 'static, - C::Error: Into>, -{ - pub fn new(url: &str, sdk_key: &str, connector: C) -> Self { +impl HttpFeatureRequesterBuilder { + pub fn new(url: &str, sdk_key: &str, transport: T) -> Self { Self { - http: HyperClient::builder(TokioExecutor::new()).build(connector), + transport, url: url.into(), sdk_key: sdk_key.into(), } } } -impl FeatureRequesterFactory for HyperFeatureRequesterBuilder -where - C: tower::Service + Clone + Send + Sync + 'static, - C::Response: hyper_util::client::legacy::connect::Connection - + hyper::rt::Read - + hyper::rt::Write - + Send - + Unpin, - C::Future: Send + Unpin + 'static, - C::Error: Into>, -{ +impl FeatureRequesterFactory for HttpFeatureRequesterBuilder { fn build(&self, tags: Option) -> Result, BuildError> { let url = format!("{}/sdk/latest-all", self.url); @@ -79,8 +53,8 @@ where let url = Uri::from_str(url.as_str()) .map_err(|_| BuildError::InvalidConfig("Invalid base url provided".into()))?; - Ok(Box::new(HyperFeatureRequester::new( - self.http.clone(), + Ok(Box::new(HttpFeatureRequester::new( + self.transport.clone(), url, self.sdk_key.clone(), default_headers, @@ -90,16 +64,15 @@ where #[cfg(test)] mod tests { - use hyper_util::client::legacy::connect::HttpConnector; - use super::*; #[test] fn factory_handles_url_parsing_failure() { - let builder = HyperFeatureRequesterBuilder::new( + let transport = crate::HyperTransport::new(); + let builder = HttpFeatureRequesterBuilder::new( "This is clearly not a valid URL", "sdk-key", - HttpConnector::new(), + transport, ); let result = builder.build(None); From af7a9f64ec27c1d34eacf8a0d2ce12e3893b12a4 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Wed, 21 Jan 2026 13:04:22 -0500 Subject: [PATCH 06/15] chore: Update contract tests and add custom transport example (#139) --- contract-tests/src/client_entity.rs | 26 ++-- contract-tests/src/main.rs | 23 +--- .../examples/custom_transport.rs | 121 ++++++++++++++++++ 3 files changed, 137 insertions(+), 33 deletions(-) create mode 100644 launchdarkly-server-sdk/examples/custom_transport.rs diff --git a/contract-tests/src/client_entity.rs b/contract-tests/src/client_entity.rs index 9c16166..d203199 100644 --- a/contract-tests/src/client_entity.rs +++ b/contract-tests/src/client_entity.rs @@ -1,4 +1,3 @@ -use eventsource_client as es; use futures::future::FutureExt; use launchdarkly_server_sdk::{ Context, ContextBuilder, MigratorBuilder, MultiContextBuilder, Reference, @@ -21,7 +20,6 @@ use crate::command_params::{ MigrationOperationResponse, MigrationVariationResponse, SecureModeHashResponse, }; use crate::HttpsConnector; -use crate::StreamingHttpsConnector; use crate::{ command_params::{ CommandParams, CommandResponse, EvaluateAllFlagsParams, EvaluateAllFlagsResponse, @@ -38,8 +36,12 @@ impl ClientEntity { pub async fn new( create_instance_params: CreateInstanceParams, connector: HttpsConnector, - streaming_https_connector: StreamingHttpsConnector, ) -> Result { + // Create fresh transports for this client to avoid shared connection pool issues + let transport = + launchdarkly_server_sdk::HyperTransport::new_with_connector(connector.clone()); + let streaming_https_transport = + eventsource_client::HyperTransport::builder().build_with_connector(connector.clone()); let mut config_builder = ConfigBuilder::new(&create_instance_params.configuration.credential); @@ -74,8 +76,6 @@ impl ClientEntity { } if let Some(streaming) = create_instance_params.configuration.streaming { - let transport = - es::HyperTransport::builder().build_with_connector(streaming_https_connector); if let Some(base_uri) = streaming.base_uri { service_endpoints_builder.streaming_base_url(&base_uri); } @@ -84,7 +84,7 @@ impl ClientEntity { if let Some(delay) = streaming.initial_retry_delay_ms { streaming_builder.initial_reconnect_delay(Duration::from_millis(delay)); } - streaming_builder.transport(transport); + streaming_builder.transport(streaming_https_transport.clone()); config_builder = config_builder.data_source(&streaming_builder); } else if let Some(polling) = create_instance_params.configuration.polling { @@ -96,19 +96,15 @@ impl ClientEntity { if let Some(delay) = polling.poll_interval_ms { polling_builder.poll_interval(Duration::from_millis(delay)); } - let transport = - launchdarkly_server_sdk::HyperTransport::new_with_connector(connector.clone()); - polling_builder.transport(transport); + polling_builder.transport(transport.clone()); config_builder = config_builder.data_source(&polling_builder); } else { // If we didn't specify streaming or polling, we fall back to basic streaming. The only - // customization we provide is the https connector to support testing multiple - // connectors. - let transport = - es::HyperTransport::builder().build_with_connector(streaming_https_connector); + // customization we provide is the transport to support testing multiple + // transport implementations. let mut streaming_builder = StreamingDataSourceBuilder::new(); - streaming_builder.transport(transport); + streaming_builder.transport(streaming_https_transport); config_builder = config_builder.data_source(&streaming_builder); } @@ -133,8 +129,6 @@ impl ClientEntity { if let Some(attributes) = events.global_private_attributes { processor_builder.private_attributes(attributes); } - let transport = - launchdarkly_server_sdk::HyperTransport::new_with_connector(connector.clone()); processor_builder.transport(transport); processor_builder.omit_anonymous_contexts(events.omit_anonymous_contexts); diff --git a/contract-tests/src/main.rs b/contract-tests/src/main.rs index 7d5e0f3..85bc036 100644 --- a/contract-tests/src/main.rs +++ b/contract-tests/src/main.rs @@ -7,7 +7,6 @@ use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer, Responder, Resu use async_mutex::Mutex; use client_entity::ClientEntity; use futures::executor; -use hyper_util::client::legacy::connect::HttpConnector; use launchdarkly_server_sdk::Reference; use serde::{self, Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; @@ -132,7 +131,6 @@ async fn create_client( let client_entity = match ClientEntity::new( create_instance_params.into_inner(), app_state.https_connector.clone(), - app_state.streaming_https_connector.clone(), ) .await { @@ -207,18 +205,14 @@ struct AppState { counter: Mutex, client_entities: Mutex>, https_connector: HttpsConnector, - streaming_https_connector: StreamingHttpsConnector, } #[cfg(feature = "hyper-rustls")] -type HttpsConnector = hyper_rustls::HttpsConnector; -#[cfg(feature = "hyper-rustls")] -type StreamingHttpsConnector = hyper_util::client::legacy::connect::HttpConnector; +type HttpsConnector = + hyper_rustls::HttpsConnector; #[cfg(feature = "tls")] -type HttpsConnector = hyper_tls::HttpsConnector; -#[cfg(feature = "tls")] -type StreamingHttpsConnector = hyper_tls::HttpsConnector; +type HttpsConnector = hyper_tls::HttpsConnector; #[actix_web::main] async fn main() -> std::io::Result<()> { @@ -238,9 +232,7 @@ async fn main() -> std::io::Result<()> { let (tx, rx) = mpsc::channel::<()>(); #[cfg(feature = "hyper-rustls")] - let streaming_https_connector = hyper_util::client::legacy::connect::HttpConnector::new(); - #[cfg(feature = "hyper-rustls")] - let connector = hyper_rustls::HttpsConnectorBuilder::new() + let https_connector = hyper_rustls::HttpsConnectorBuilder::new() .with_native_roots() .expect("Failed to load native root certificates") .https_or_http() @@ -249,15 +241,12 @@ async fn main() -> std::io::Result<()> { .build(); #[cfg(feature = "tls")] - let streaming_https_connector = hyper_tls::HttpsConnector::new(); - #[cfg(feature = "tls")] - let connector = hyper_tls::HttpsConnector::new(); + let https_connector = hyper_tls::HttpsConnector::new(); let state = web::Data::new(AppState { counter: Mutex::new(0), client_entities: Mutex::new(HashMap::new()), - https_connector: connector, - streaming_https_connector, + https_connector, }); let server = HttpServer::new(move || { diff --git a/launchdarkly-server-sdk/examples/custom_transport.rs b/launchdarkly-server-sdk/examples/custom_transport.rs new file mode 100644 index 0000000..ecdb895 --- /dev/null +++ b/launchdarkly-server-sdk/examples/custom_transport.rs @@ -0,0 +1,121 @@ +use bytes::Bytes; +use http::Request; +use launchdarkly_server_sdk::{ + ConfigBuilder, EventProcessorBuilder, HttpTransport, ResponseFuture, +}; +use std::time::Instant; + +/// Example of a custom transport that wraps another transport and adds logging. +/// +/// This demonstrates how to implement the HttpTransport trait to add middleware +/// functionality like logging, metrics, retries, circuit breakers, etc. +#[derive(Clone)] +struct LoggingTransport { + inner: T, +} + +impl LoggingTransport { + fn new(inner: T) -> Self { + Self { inner } + } +} + +impl HttpTransport for LoggingTransport { + fn request(&self, request: Request) -> ResponseFuture { + let method = request.method().clone(); + let uri = request.uri().clone(); + let start = Instant::now(); + + println!("[REQUEST] {method} {uri}"); + + let inner = self.inner.clone(); + Box::pin(async move { + let result = inner.request(request).await; + let elapsed = start.elapsed(); + + match &result { + Ok(response) => { + println!( + "[RESPONSE] {} {} - Status: {} - Duration: {:?}", + method, + uri, + response.status(), + elapsed + ); + } + Err(e) => { + println!("[ERROR] {method} {uri} - Error: {e} - Duration: {elapsed:?}"); + } + } + + result + }) + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Get SDK key from environment + let sdk_key = + std::env::var("LAUNCHDARKLY_SDK_KEY").unwrap_or_else(|_| "your-sdk-key".to_string()); + + if sdk_key == "your-sdk-key" { + eprintln!("Please set LAUNCHDARKLY_SDK_KEY environment variable"); + std::process::exit(1); + } + + // Create the base HTTPS transport + let base_transport = launchdarkly_server_sdk::HyperTransport::new_https(); + + // Wrap it with logging middleware + let logging_transport = LoggingTransport::new(base_transport); + + // Configure the SDK to use the custom transport + let config = ConfigBuilder::new(&sdk_key) + .event_processor( + EventProcessorBuilder::new() + .transport(logging_transport.clone()) + .flush_interval(std::time::Duration::from_secs(5)), + ) + .build()?; + + // Create the client - you'll see all HTTP requests logged + println!("Initializing LaunchDarkly client with logging transport..."); + let client = launchdarkly_server_sdk::Client::build(config)?; + client.start_with_default_executor(); + + // Wait for initialization + println!("Waiting for client initialization..."); + match client + .wait_for_initialization(std::time::Duration::from_secs(10)) + .await + { + Some(true) => { + println!("Client initialized successfully!"); + + // Evaluate a flag (will trigger HTTP events) + let context = launchdarkly_server_sdk::ContextBuilder::new("example-user-key") + .build() + .expect("Failed to create context"); + + let flag_value = client.bool_variation(&context, "example-flag", false); + println!("Flag 'example-flag' evaluated to: {flag_value}"); + + // Wait a bit to see event flushing + println!("Waiting to observe event flushing..."); + tokio::time::sleep(std::time::Duration::from_secs(6)).await; + } + Some(false) => { + eprintln!("Client failed to initialize"); + } + None => { + eprintln!("Client initialization timed out"); + } + } + + // Shutdown the client + println!("Shutting down client..."); + client.close(); + + Ok(()) +} From 9461063348027cc9098bf83b438c7b36d7291ecb Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Wed, 21 Jan 2026 13:05:54 -0500 Subject: [PATCH 07/15] feat!: Remove deprecated initialized_async function (#140) --- launchdarkly-server-sdk/src/client.rs | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/launchdarkly-server-sdk/src/client.rs b/launchdarkly-server-sdk/src/client.rs index f3ece21..e9e387b 100644 --- a/launchdarkly-server-sdk/src/client.rs +++ b/launchdarkly-server-sdk/src/client.rs @@ -273,16 +273,6 @@ impl Client { Ok(true) } - /// This is an async method that will resolve once initialization is complete. - /// Initialization being complete does not mean that initialization was a success. - /// The return value from the method indicates if the client successfully initialized. - #[deprecated( - note = "blocking without a timeout is discouraged, use wait_for_initialization instead" - )] - pub async fn initialized_async(&self) -> bool { - self.initialized_async_internal().await - } - /// This is an async method that will resolve once initialization is complete or the specified /// timeout has occurred. /// @@ -860,19 +850,6 @@ mod tests { is_send_and_sync::() } - #[tokio::test] - async fn client_asynchronously_initializes() { - let (client, _event_rx) = make_mocked_client_with_delay(1000, false, false); - client.start_with_default_executor(); - - let now = Instant::now(); - let initialized = client.initialized_async().await; - let elapsed_time = now.elapsed(); - assert!(initialized); - // Give ourself a good margin for thread scheduling. - assert!(elapsed_time.as_millis() > 500) - } - #[tokio::test] async fn client_asynchronously_initializes_within_timeout() { let (client, _event_rx) = make_mocked_client_with_delay(1000, false, false); From 85ad7b44ad9db0224da270a9f5a1f791017bd7c0 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Wed, 28 Jan 2026 16:12:51 -0500 Subject: [PATCH 08/15] feat!: Allow filtering all_flags_state to mobile only (#142) fixes #67 --- contract-tests/src/client_entity.rs | 4 +- launchdarkly-server-sdk/Cargo.toml | 1 + launchdarkly-server-sdk/src/client.rs | 9 +- launchdarkly-server-sdk/src/evaluation.rs | 181 ++++++++++++++++++--- launchdarkly-server-sdk/src/lib.rs | 2 +- launchdarkly-server-sdk/src/test_common.rs | 17 +- 6 files changed, 183 insertions(+), 31 deletions(-) diff --git a/contract-tests/src/client_entity.rs b/contract-tests/src/client_entity.rs index d203199..1a72648 100644 --- a/contract-tests/src/client_entity.rs +++ b/contract-tests/src/client_entity.rs @@ -11,7 +11,7 @@ const DEFAULT_EVENTS_BASE_URL: &str = "https://events.launchdarkly.com"; use launchdarkly_server_sdk::{ ApplicationInfo, BuildError, Client, ConfigBuilder, Detail, EventProcessorBuilder, - FlagDetailConfig, FlagValue, NullEventProcessorBuilder, PollingDataSourceBuilder, + FlagDetailConfig, FlagFilter, FlagValue, NullEventProcessorBuilder, PollingDataSourceBuilder, ServiceEndpointsBuilder, StreamingDataSourceBuilder, }; @@ -556,7 +556,7 @@ impl ClientEntity { } if params.client_side_only { - config.client_side_only(); + config.flag_filter(FlagFilter::CLIENT); } if params.details_only_for_tracked_flags { diff --git a/launchdarkly-server-sdk/Cargo.toml b/launchdarkly-server-sdk/Cargo.toml index 0979ab5..406249c 100644 --- a/launchdarkly-server-sdk/Cargo.toml +++ b/launchdarkly-server-sdk/Cargo.toml @@ -37,6 +37,7 @@ moka = { version = "0.12.1", features = ["sync"] } uuid = {version = "1.2.2", features = ["v4"] } http = "1.0" bytes = "1.11" +bitflags = "2.4" hyper = { version = "1.0", features = ["client", "http1", "http2"], optional = true } hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2", "tokio"], optional = true } http-body-util = { version = "0.1", optional = true } diff --git a/launchdarkly-server-sdk/src/client.rs b/launchdarkly-server-sdk/src/client.rs index e9e387b..e6069ed 100644 --- a/launchdarkly-server-sdk/src/client.rs +++ b/launchdarkly-server-sdk/src/client.rs @@ -825,6 +825,7 @@ mod tests { use crate::data_source::MockDataSource; use crate::data_source_builders::MockDataSourceBuilder; + use crate::evaluation::FlagFilter; use crate::events::create_event_sender; use crate::events::event::{OutputEvent, VariationKey}; use crate::events::processor_builders::EventProcessorBuilder; @@ -1051,6 +1052,7 @@ mod tests { "toplevel", &["prereq1", "prereq2"], false, + false, ))), ) .expect("patch should apply"); @@ -1099,7 +1101,7 @@ mod tests { .upsert( "prereq1", PatchTarget::Flag(StorageItem::Item(basic_flag_with_visibility( - "prereq1", false, + "prereq1", false, false, ))), ) .expect("patch should apply"); @@ -1109,7 +1111,7 @@ mod tests { .upsert( "prereq2", PatchTarget::Flag(StorageItem::Item(basic_flag_with_visibility( - "prereq2", false, + "prereq2", false, false, ))), ) .expect("patch should apply"); @@ -1123,6 +1125,7 @@ mod tests { "toplevel", &["prereq1", "prereq2"], true, + false, ))), ) .expect("patch should apply"); @@ -1132,7 +1135,7 @@ mod tests { .expect("Failed to create context"); let mut config = FlagDetailConfig::new(); - config.client_side_only(); + config.flag_filter(FlagFilter::CLIENT); let all_flags = client.all_flags_detail(&context, config); diff --git a/launchdarkly-server-sdk/src/evaluation.rs b/launchdarkly-server-sdk/src/evaluation.rs index 75af925..ca0873f 100644 --- a/launchdarkly-server-sdk/src/evaluation.rs +++ b/launchdarkly-server-sdk/src/evaluation.rs @@ -1,4 +1,5 @@ use super::stores::store::DataStore; +use bitflags::bitflags; use serde::Serialize; use std::cell::RefCell; @@ -8,22 +9,61 @@ use launchdarkly_server_sdk_evaluation::{ use std::collections::HashMap; use std::time::SystemTime; +bitflags! { + /// Controls which flags are included based on their client-side availability settings. + /// + /// Use this with [FlagDetailConfig] to filter flags returned by [crate::Client::all_flags_detail]. + /// + /// # Examples + /// + /// ``` + /// # use launchdarkly_server_sdk::{FlagDetailConfig, FlagFilter}; + /// // Include only web/JavaScript client flags + /// let mut config = FlagDetailConfig::new(); + /// config.flag_filter(FlagFilter::CLIENT); + /// + /// // Include both web and mobile client flags + /// let mut config = FlagDetailConfig::new(); + /// config.flag_filter(FlagFilter::CLIENT | FlagFilter::MOBILE); + /// + /// // Include all flags (default) + /// let config = FlagDetailConfig::new(); // empty filter = no filtering + /// ``` + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct FlagFilter: u8 { + /// Include flags available to JavaScript/web client-side SDKs. + /// Filters to flags where `using_environment_id()` returns true. + const CLIENT = 0b01; + + /// Include flags available to mobile/desktop client-side SDKs. + /// Filters to flags where `using_mobile_key()` returns true. + const MOBILE = 0b10; + } +} + +impl Default for FlagFilter { + fn default() -> Self { + // Empty filter = include all flags (no filtering) + Self::empty() + } +} + /// Configuration struct to control the type of data returned from the [crate::Client::all_flags_detail] /// method. By default, each of the options default to false. However, you can selectively enable /// them by calling the appropriate functions. /// /// ``` -/// # use launchdarkly_server_sdk::FlagDetailConfig; +/// # use launchdarkly_server_sdk::{FlagDetailConfig, FlagFilter}; /// # fn main() { /// let mut config = FlagDetailConfig::new(); -/// config.client_side_only() +/// config.flag_filter(FlagFilter::CLIENT) /// .with_reasons() /// .details_only_for_tracked_flags(); /// # } /// ``` #[derive(Clone, Copy, Default)] pub struct FlagDetailConfig { - client_side_only: bool, + flag_filter: FlagFilter, with_reasons: bool, details_only_for_tracked_flags: bool, } @@ -34,16 +74,18 @@ impl FlagDetailConfig { /// By default, this config will include al flags and will not include reasons. pub fn new() -> Self { Self { - client_side_only: false, + flag_filter: FlagFilter::default(), with_reasons: false, details_only_for_tracked_flags: false, } } - /// Limit to only flags that are marked for use with the client-side SDK (by - /// default, all flags are included) - pub fn client_side_only(&mut self) -> &mut Self { - self.client_side_only = true; + /// Set the flag filter to control which flags are included. + /// + /// Pass an empty filter (default) to include all flags. + /// Use `FlagFilter::CLIENT`, `FlagFilter::MOBILE`, or combine them. + pub fn flag_filter(&mut self, filter: FlagFilter) -> &mut Self { + self.flag_filter = filter; self } @@ -148,8 +190,14 @@ impl FlagDetail { let mut flag_state = HashMap::new(); for (key, flag) in store.all_flags() { - if config.client_side_only && !flag.using_environment_id() { - continue; + if !config.flag_filter.is_empty() { + let matches_filter = (config.flag_filter.contains(FlagFilter::CLIENT) + && flag.using_environment_id()) + || (config.flag_filter.contains(FlagFilter::MOBILE) && flag.using_mobile_key()); + + if !matches_filter { + continue; + } } let event_recorder = DirectPrerequisiteRecorder::new(key.clone()); @@ -219,7 +267,7 @@ impl FlagDetail { #[cfg(test)] mod tests { - use crate::evaluation::FlagDetail; + use crate::evaluation::{FlagDetail, FlagFilter}; use crate::stores::store::DataStore; use crate::stores::store::InMemoryDataStore; use crate::stores::store_types::{PatchTarget, StorageItem}; @@ -230,6 +278,7 @@ mod tests { use crate::FlagDetailConfig; use assert_json_diff::assert_json_eq; use launchdarkly_server_sdk_evaluation::ContextBuilder; + use test_case::test_case; #[test] fn flag_detail_handles_default_configuration() { @@ -461,8 +510,12 @@ mod tests { let prereq1 = basic_flag("prereq1"); let prereq2 = basic_flag("prereq2"); - let toplevel = - basic_flag_with_prereqs_and_visibility("toplevel", &["prereq1", "prereq2"], false); + let toplevel = basic_flag_with_prereqs_and_visibility( + "toplevel", + &["prereq1", "prereq2"], + false, + false, + ); store .upsert("prereq1", PatchTarget::Flag(StorageItem::Item(prereq1))) @@ -512,12 +565,16 @@ mod tests { let mut store = InMemoryDataStore::new(); // These two prerequisites won't be visible to clients (environment ID) SDKs. - let prereq1 = basic_flag_with_visibility("prereq1", false); - let prereq2 = basic_flag_with_visibility("prereq2", false); + let prereq1 = basic_flag_with_visibility("prereq1", false, false); + let prereq2 = basic_flag_with_visibility("prereq2", false, false); // But, the top-level flag will. - let toplevel = - basic_flag_with_prereqs_and_visibility("toplevel", &["prereq1", "prereq2"], true); + let toplevel = basic_flag_with_prereqs_and_visibility( + "toplevel", + &["prereq1", "prereq2"], + true, + false, + ); store .upsert("prereq1", PatchTarget::Flag(StorageItem::Item(prereq1))) @@ -534,7 +591,7 @@ mod tests { let mut flag_detail = FlagDetail::new(true); let mut config = FlagDetailConfig::new(); - config.client_side_only(); + config.flag_filter(FlagFilter::CLIENT); flag_detail.populate(&store, &context, config); @@ -567,8 +624,12 @@ mod tests { let prereq1 = basic_off_flag("prereq1"); let prereq2 = basic_flag("prereq2"); - let toplevel = - basic_flag_with_prereqs_and_visibility("toplevel", &["prereq1", "prereq2"], true); + let toplevel = basic_flag_with_prereqs_and_visibility( + "toplevel", + &["prereq1", "prereq2"], + true, + false, + ); store .upsert("prereq1", PatchTarget::Flag(StorageItem::Item(prereq1))) @@ -610,4 +671,84 @@ mod tests { assert_json_eq!(expected, flag_detail); } + + #[test_case(FlagFilter::empty(), &["server-flag", "client-flag", "mobile-flag", "both-flag"] ; "empty filter includes all flags")] + #[test_case(FlagFilter::CLIENT, &["client-flag", "both-flag"] ; "client filter includes only client flags")] + #[test_case(FlagFilter::MOBILE, &["mobile-flag", "both-flag"] ; "mobile filter includes only mobile flags")] + #[test_case(FlagFilter::CLIENT | FlagFilter::MOBILE, &["client-flag", "mobile-flag", "both-flag"] ; "combined filter includes client or mobile flags")] + fn flag_filter_includes_correct_flags(filter: FlagFilter, expected_flags: &[&str]) { + let context = ContextBuilder::new("bob") + .build() + .expect("Failed to create context"); + let mut store = InMemoryDataStore::new(); + + // Add different types of flags + store + .upsert( + "server-flag", + PatchTarget::Flag(StorageItem::Item(basic_flag_with_visibility( + "server-flag", + false, + false, + ))), + ) + .expect("patch should apply"); + + store + .upsert( + "client-flag", + PatchTarget::Flag(StorageItem::Item(basic_flag_with_visibility( + "client-flag", + true, + false, + ))), + ) + .expect("patch should apply"); + + store + .upsert( + "mobile-flag", + PatchTarget::Flag(StorageItem::Item(basic_flag_with_visibility( + "mobile-flag", + false, + true, + ))), + ) + .expect("patch should apply"); + + store + .upsert( + "both-flag", + PatchTarget::Flag(StorageItem::Item(basic_flag_with_visibility( + "both-flag", + true, + true, + ))), + ) + .expect("patch should apply"); + + let mut flag_detail = FlagDetail::new(true); + let mut config = FlagDetailConfig::new(); + if !filter.is_empty() { + config.flag_filter(filter); + } + flag_detail.populate(&store, &context, config); + + // Assert expected flags are present + for expected_flag in expected_flags { + assert!( + flag_detail.evaluations.contains_key(*expected_flag), + "Expected flag '{expected_flag}' to be present" + ); + } + + // Assert count matches + assert_eq!( + flag_detail.evaluations.len(), + expected_flags.len(), + "Expected {} flags, got {}", + expected_flags.len(), + flag_detail.evaluations.len() + ); + } } diff --git a/launchdarkly-server-sdk/src/lib.rs b/launchdarkly-server-sdk/src/lib.rs index 5847d9d..d3f707c 100644 --- a/launchdarkly-server-sdk/src/lib.rs +++ b/launchdarkly-server-sdk/src/lib.rs @@ -31,7 +31,7 @@ pub use config::{ApplicationInfo, BuildError as ConfigBuildError, Config, Config pub use data_source_builders::{ BuildError as DataSourceBuildError, PollingDataSourceBuilder, StreamingDataSourceBuilder, }; -pub use evaluation::{FlagDetail, FlagDetailConfig}; +pub use evaluation::{FlagDetail, FlagDetailConfig, FlagFilter}; pub use events::event::MigrationOpEvent; pub use events::processor::EventProcessor; pub use events::processor_builders::{ diff --git a/launchdarkly-server-sdk/src/test_common.rs b/launchdarkly-server-sdk/src/test_common.rs index c5f167c..b2bbe52 100644 --- a/launchdarkly-server-sdk/src/test_common.rs +++ b/launchdarkly-server-sdk/src/test_common.rs @@ -7,10 +7,14 @@ use crate::Stage; pub const FLOAT_TO_INT_MAX: i64 = 9007199254740991; pub fn basic_flag(key: &str) -> Flag { - basic_flag_with_visibility(key, false) + basic_flag_with_visibility(key, false, false) } -pub fn basic_flag_with_visibility(key: &str, visible_to_environment_id: bool) -> Flag { +pub fn basic_flag_with_visibility( + key: &str, + visible_to_environment_id: bool, + visible_to_mobile_key: bool, +) -> Flag { serde_json::from_str(&format!( r#"{{ "key": {}, @@ -23,12 +27,13 @@ pub fn basic_flag_with_visibility(key: &str, visible_to_environment_id: bool) -> "offVariation": 0, "variations": [false, true], "clientSideAvailability": {{ - "usingMobileKey": false, + "usingMobileKey": {}, "usingEnvironmentId": {} }}, "salt": "kosher" }}"#, serde_json::Value::String(key.to_string()), + visible_to_mobile_key, visible_to_environment_id )) .unwrap() @@ -57,13 +62,14 @@ pub fn basic_off_flag(key: &str) -> Flag { } pub fn basic_flag_with_prereq(key: &str, prereq_key: &str) -> Flag { - basic_flag_with_prereqs_and_visibility(key, &[prereq_key], false) + basic_flag_with_prereqs_and_visibility(key, &[prereq_key], false, false) } pub fn basic_flag_with_prereqs_and_visibility( key: &str, prereq_keys: &[&str], visible_to_environment_id: bool, + visible_to_mobile_key: bool, ) -> Flag { let prereqs_json: String = prereq_keys .iter() @@ -88,13 +94,14 @@ pub fn basic_flag_with_prereqs_and_visibility( "offVariation": 0, "variations": [false, true], "clientSideAvailability": {{ - "usingMobileKey": false, + "usingMobileKey": {}, "usingEnvironmentId": {} }}, "salt": "kosher" }}"#, serde_json::Value::String(key.to_string()), prereqs_json, + visible_to_mobile_key, visible_to_environment_id )) .unwrap() From e00b82d42db0b4a8973c72a21ff8b2c7ecb27e28 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Thu, 29 Jan 2026 12:03:47 -0500 Subject: [PATCH 09/15] feat: Support synchronous event sending with new `flush_blocking` method (#143) fixes #7 --- launchdarkly-server-sdk/src/client.rs | 143 ++++++++ .../src/events/dispatcher.rs | 168 +++++++++- .../src/events/processor.rs | 315 +++++++++++++++++- launchdarkly-server-sdk/src/events/sender.rs | 17 +- 4 files changed, 631 insertions(+), 12 deletions(-) diff --git a/launchdarkly-server-sdk/src/client.rs b/launchdarkly-server-sdk/src/client.rs index e6069ed..9232cc5 100644 --- a/launchdarkly-server-sdk/src/client.rs +++ b/launchdarkly-server-sdk/src/client.rs @@ -342,6 +342,60 @@ impl Client { self.event_processor.flush(); } + /// Flush tells the client that all pending analytics events should be delivered as + /// soon as possible, and blocks until delivery is complete or the timeout expires. + /// + /// This method is particularly useful in short-lived execution environments like AWS Lambda + /// where you need to ensure events are sent before the function terminates. + /// + /// This method triggers a flush of events currently buffered and waits for that specific + /// flush to complete. Note that if periodic flushes or other flush operations are in-flight + /// when this is called, those may still be completing after this method returns. + /// + /// # Arguments + /// + /// * `timeout` - Maximum time to wait for flush to complete. Use `Duration::ZERO` to wait indefinitely. + /// + /// # Returns + /// + /// Returns `true` if flush completed successfully, `false` if timeout occurred. + /// + /// # Examples + /// + /// ```no_run + /// # use launchdarkly_server_sdk::{Client, ConfigBuilder}; + /// # use std::time::Duration; + /// # async fn example() { + /// # let client = Client::build(ConfigBuilder::new("sdk-key").build().unwrap()).unwrap(); + /// // Wait up to 5 seconds for flush to complete + /// let success = client.flush_blocking(Duration::from_secs(5)).await; + /// if !success { + /// eprintln!("Warning: flush timed out"); + /// } + /// # } + /// ``` + /// + /// For more information, see the Reference Guide: + /// . + pub async fn flush_blocking(&self, timeout: Duration) -> bool { + let event_processor = self.event_processor.clone(); + + let flush_future = + tokio::task::spawn_blocking(move || event_processor.flush_blocking(timeout)); + + if timeout == Duration::ZERO { + // Wait indefinitely + flush_future.await.unwrap_or(false) + } else { + // Apply timeout at async level too + match tokio::time::timeout(timeout, flush_future).await { + Ok(Ok(result)) => result, + Ok(Err(_)) => false, // spawn_blocking panicked + Err(_) => false, // Timeout + } + } + } + /// Identify reports details about a context. /// /// For more information, see the Reference Guide: @@ -2566,6 +2620,95 @@ mod tests { } } + #[tokio::test] + async fn client_flush_blocking_completes_successfully() { + let (client, event_rx) = make_mocked_client(); + client.start_with_default_executor(); + client.wait_for_initialization(Duration::from_secs(1)).await; + + let context = ContextBuilder::new("user-key") + .build() + .expect("Failed to create context"); + + client.identify(context); + + let result = client.flush_blocking(Duration::from_secs(5)).await; + assert!(result, "flush_blocking should complete successfully"); + + client.close(); + + let events = event_rx.iter().collect::>(); + assert!(!events.is_empty(), "Should have received identify event"); + } + + #[tokio::test] + async fn client_flush_blocking_with_zero_timeout() { + let (client, event_rx) = make_mocked_client(); + client.start_with_default_executor(); + client.wait_for_initialization(Duration::from_secs(1)).await; + + let context = ContextBuilder::new("user-key") + .build() + .expect("Failed to create context"); + + client.identify(context); + + let result = client.flush_blocking(Duration::ZERO).await; + assert!( + result, + "flush_blocking with zero timeout should complete successfully" + ); + + client.close(); + + let events = event_rx.iter().collect::>(); + assert!(!events.is_empty(), "Should have received identify event"); + } + + #[tokio::test] + async fn client_flush_blocking_with_no_events() { + let (client, _event_rx) = make_mocked_client(); + client.start_with_default_executor(); + client.wait_for_initialization(Duration::from_secs(1)).await; + + let result = client.flush_blocking(Duration::from_secs(1)).await; + assert!( + result, + "flush_blocking with no events should complete immediately" + ); + + client.close(); + } + + #[tokio::test] + async fn client_flush_blocking_multiple_concurrent_calls() { + let (client, event_rx) = make_mocked_client(); + client.start_with_default_executor(); + client.wait_for_initialization(Duration::from_secs(1)).await; + + let context = ContextBuilder::new("user-key") + .build() + .expect("Failed to create context"); + + client.identify(context); + + // Make multiple concurrent flush_blocking calls + let (result1, result2, result3) = tokio::join!( + client.flush_blocking(Duration::from_secs(5)), + client.flush_blocking(Duration::from_secs(5)), + client.flush_blocking(Duration::from_secs(5)), + ); + + assert!(result1, "First flush_blocking should succeed"); + assert!(result2, "Second flush_blocking should succeed"); + assert!(result3, "Third flush_blocking should succeed"); + + client.close(); + + let events = event_rx.iter().collect::>(); + assert!(!events.is_empty(), "Should have received identify event"); + } + fn make_mocked_client_with_delay( delay: u64, offline: bool, diff --git a/launchdarkly-server-sdk/src/events/dispatcher.rs b/launchdarkly-server-sdk/src/events/dispatcher.rs index 5921fb5..73b86ac 100644 --- a/launchdarkly-server-sdk/src/events/dispatcher.rs +++ b/launchdarkly-server-sdk/src/events/dispatcher.rs @@ -114,6 +114,7 @@ impl EventDispatcher { }; let (send, recv) = bounded::<()>(1); + let mut flush_signal: Option> = None; loop { debug!("waiting for a batch to send"); @@ -121,12 +122,15 @@ impl EventDispatcher { loop { select! { recv(event_result_rx) -> result => match result { - Ok(result) if result.success => self.last_known_time = std::cmp::max(result.time_from_server, self.last_known_time), - Ok(result) if result.must_shutdown => { - self.disabled = true; - self.outbox.reset(); - }, - Ok(_) => continue, + Ok(result) => { + if result.success { + self.last_known_time = std::cmp::max(result.time_from_server, self.last_known_time); + } else if result.must_shutdown { + self.disabled = true; + self.outbox.reset(); + } + result.flush_signal.map(|s| s.send(())); + } Err(e) => { error!("event_result_rx is disconnected. Shutting down dispatcher: {e}"); return; @@ -136,6 +140,10 @@ impl EventDispatcher { recv(flush_ticker) -> _ => break, recv(inbox_rx) -> result => match result { Ok(EventDispatcherMessage::Flush) => break, + Ok(EventDispatcherMessage::FlushWithReply(reply_sender)) => { + flush_signal = Some(reply_sender); + break; + } Ok(EventDispatcherMessage::EventMessage(event)) => { if !self.disabled { self.process_event(event); @@ -166,6 +174,7 @@ impl EventDispatcher { } if self.disabled { + flush_signal.take().map(|s| s.send(())); continue; } @@ -177,10 +186,14 @@ impl EventDispatcher { let sender = self.events_configuration.event_sender.clone(); let results = event_result_tx.clone(); let send = send.clone(); + let fs = flush_signal.take(); rt.spawn(async move { - sender.send_event_data(payload, results).await; + sender.send_event_data(payload, results, fs).await; drop(send); }); + } else { + // No events to send, reply immediately to any waiting flush_blocking calls + flush_signal.take().map(|s| s.send(())); } } } @@ -317,6 +330,7 @@ pub(super) enum EventDispatcherMessage { EventMessage(InputEvent), Flush, Close(Sender<()>), + FlushWithReply(Sender<()>), } #[cfg(test)] @@ -828,6 +842,146 @@ mod tests { assert_eq!(event_rx.try_iter().count(), 1); } + #[test] + fn flush_blocking_returns_immediately_when_outbox_empty() { + let (event_sender, event_rx) = create_event_sender(); + let events_configuration = + create_events_configuration(event_sender, Duration::from_secs(100)); + let (inbox_tx, inbox_rx) = bounded(events_configuration.capacity); + + let dispatcher_handle = thread::Builder::new() + .spawn(move || { + let mut dispatcher = create_dispatcher(events_configuration); + dispatcher.start(inbox_rx) + }) + .unwrap(); + + // Send FlushWithReply without any events + let (tx, rx) = bounded(1); + inbox_tx + .send(EventDispatcherMessage::FlushWithReply(tx)) + .expect("flush with reply failed"); + + // Should receive signal immediately since outbox is empty + rx.recv_timeout(Duration::from_millis(500)) + .expect("should receive flush signal immediately when outbox is empty"); + + let (close_tx, close_rx) = bounded(1); + inbox_tx + .send(EventDispatcherMessage::Close(close_tx)) + .expect("failed to close"); + close_rx.recv().expect("failed to notify on close"); + dispatcher_handle.join().unwrap(); + + assert_eq!(event_rx.try_iter().count(), 0); + } + + #[test] + fn flush_blocking_signals_after_send_completes() { + let (event_sender, event_rx) = create_event_sender(); + let events_configuration = + create_events_configuration(event_sender, Duration::from_secs(100)); + let (inbox_tx, inbox_rx) = bounded(events_configuration.capacity); + + let dispatcher_handle = thread::Builder::new() + .spawn(move || { + let mut dispatcher = create_dispatcher(events_configuration); + dispatcher.start(inbox_rx) + }) + .unwrap(); + + let context = ContextBuilder::new("context") + .build() + .expect("Failed to create context"); + let event_factory = EventFactory::new(true); + + // Send an event + inbox_tx + .send(EventDispatcherMessage::EventMessage( + event_factory.new_identify(context), + )) + .expect("event send failed"); + + // Send FlushWithReply + let (tx, rx) = bounded(1); + inbox_tx + .send(EventDispatcherMessage::FlushWithReply(tx)) + .expect("flush with reply failed"); + + // Should receive signal after send completes + rx.recv_timeout(Duration::from_secs(5)) + .expect("should receive flush signal after send completes"); + + let (close_tx, close_rx) = bounded(1); + inbox_tx + .send(EventDispatcherMessage::Close(close_tx)) + .expect("failed to close"); + close_rx.recv().expect("failed to notify on close"); + dispatcher_handle.join().unwrap(); + + assert_eq!(event_rx.iter().count(), 1); + } + + #[test] + fn flush_blocking_with_multiple_concurrent_requests() { + let (event_sender, event_rx) = create_event_sender(); + let events_configuration = + create_events_configuration(event_sender, Duration::from_secs(100)); + let (inbox_tx, inbox_rx) = bounded(events_configuration.capacity); + + let dispatcher_handle = thread::Builder::new() + .spawn(move || { + let mut dispatcher = create_dispatcher(events_configuration); + dispatcher.start(inbox_rx) + }) + .unwrap(); + + let context = ContextBuilder::new("context") + .build() + .expect("Failed to create context"); + let event_factory = EventFactory::new(true); + + // Send an event + inbox_tx + .send(EventDispatcherMessage::EventMessage( + event_factory.new_identify(context), + )) + .expect("event send failed"); + + // Send multiple FlushWithReply requests + let (tx1, rx1) = bounded(1); + let (tx2, rx2) = bounded(1); + let (tx3, rx3) = bounded(1); + + inbox_tx + .send(EventDispatcherMessage::FlushWithReply(tx1)) + .expect("flush1 failed"); + inbox_tx + .send(EventDispatcherMessage::FlushWithReply(tx2)) + .expect("flush2 failed"); + inbox_tx + .send(EventDispatcherMessage::FlushWithReply(tx3)) + .expect("flush3 failed"); + + // All should complete (though only first triggers actual send) + rx1.recv_timeout(Duration::from_secs(5)) + .expect("rx1 should complete"); + rx2.recv_timeout(Duration::from_secs(5)) + .expect("rx2 should complete"); + rx3.recv_timeout(Duration::from_secs(5)) + .expect("rx3 should complete"); + + let (close_tx, close_rx) = bounded(1); + inbox_tx + .send(EventDispatcherMessage::Close(close_tx)) + .expect("failed to close"); + close_rx.recv().expect("failed to notify on close"); + dispatcher_handle.join().unwrap(); + + // Should have one event from first flush (others had empty outbox) + assert_eq!(event_rx.iter().count(), 1); + } + fn create_dispatcher(events_configuration: EventsConfiguration) -> EventDispatcher { EventDispatcher::new(events_configuration) } diff --git a/launchdarkly-server-sdk/src/events/processor.rs b/launchdarkly-server-sdk/src/events/processor.rs index 9296602..a6f72f4 100644 --- a/launchdarkly-server-sdk/src/events/processor.rs +++ b/launchdarkly-server-sdk/src/events/processor.rs @@ -1,6 +1,7 @@ -use crossbeam_channel::{bounded, Sender}; +use crossbeam_channel::{bounded, RecvTimeoutError, Sender}; use std::sync::Once; use std::thread; +use std::time::Duration; use thiserror::Error; use super::dispatcher::{EventDispatcher, EventDispatcherMessage}; @@ -31,6 +32,23 @@ pub trait EventProcessor: Send + Sync { /// delivered. Subsequent calls to [EventProcessor::send] or [EventProcessor::flush] will be /// ignored. fn close(&self); + + /// Tells the event processor that all pending analytics events should be delivered as soon as + /// possible, and blocks until delivery is complete or the timeout expires. + /// + /// This method triggers a flush of events currently in the outbox and waits for that specific + /// flush to complete. Note that if periodic flushes or other flush operations are in-flight + /// when this is called, those may still be completing after this method returns. + /// + /// # Arguments + /// + /// * `timeout` - Maximum time to wait for flush to complete. Use `Duration::ZERO` to wait indefinitely. + /// + /// # Returns + /// + /// Returns `true` if flush completed successfully, `false` if timeout occurred or the event + /// processor has been shut down. + fn flush_blocking(&self, timeout: std::time::Duration) -> bool; } pub struct NullEventProcessor {} @@ -45,6 +63,9 @@ impl EventProcessor for NullEventProcessor { fn send(&self, _: InputEvent) {} fn flush(&self) {} fn close(&self) {} + fn flush_blocking(&self, _timeout: std::time::Duration) -> bool { + true + } } pub struct EventProcessorImpl { @@ -106,6 +127,43 @@ impl EventProcessor for EventProcessorImpl { let _ = receiver.recv(); } + + fn flush_blocking(&self, timeout: Duration) -> bool { + let (sender, receiver) = bounded::<()>(1); + + if self + .inbox_tx + .send(EventDispatcherMessage::FlushWithReply(sender)) + .is_err() + { + error!("Failed to send flush_blocking message"); + return false; + } + + if timeout == Duration::ZERO { + // Wait indefinitely + match receiver.recv() { + Ok(()) => true, + Err(_) => { + error!("flush_blocking failed: event processor shut down"); + false + } + } + } else { + // Wait with timeout + match receiver.recv_timeout(timeout) { + Ok(()) => true, + Err(RecvTimeoutError::Timeout) => { + warn!("flush_blocking timed out after {timeout:?}"); + false + } + Err(RecvTimeoutError::Disconnected) => { + error!("flush_blocking failed: event processor shut down"); + false + } + } + } + } } #[cfg(test)] @@ -125,6 +183,37 @@ mod tests { use super::*; + // Helper to create a failing event sender for testing + struct FailingEventSender { + should_shutdown: bool, + } + + impl FailingEventSender { + fn new(should_shutdown: bool) -> Self { + Self { should_shutdown } + } + } + + impl crate::events::sender::EventSender for FailingEventSender { + fn send_event_data( + &self, + _events: Vec, + result_tx: crossbeam_channel::Sender, + flush_signal: Option>, + ) -> futures::future::BoxFuture<'static, ()> { + let should_shutdown = self.should_shutdown; + Box::pin(async move { + // Simulate a failed HTTP send + let _ = result_tx.send(crate::events::sender::EventSenderResult { + time_from_server: 0, + success: false, + must_shutdown: should_shutdown, + flush_signal, + }); + }) + } + } + #[test] fn calling_close_on_processor_twice_returns() { let (event_sender, _) = create_event_sender(); @@ -372,4 +461,228 @@ mod tests { 1 ); } + + #[test] + fn flush_blocking_completes_successfully() { + let (event_sender, event_rx) = create_event_sender(); + let events_configuration = + create_events_configuration(event_sender, Duration::from_secs(100)); + let event_processor = + EventProcessorImpl::new(events_configuration).expect("failed to start ep"); + + let context = ContextBuilder::new("foo") + .build() + .expect("Failed to create context"); + let event_factory = EventFactory::new(true); + event_processor.send(event_factory.new_identify(context)); + + let result = event_processor.flush_blocking(Duration::from_secs(5)); + assert!(result, "flush_blocking should complete successfully"); + + event_processor.close(); + let events = event_rx.iter().collect::>(); + assert_eq!(events.len(), 1); + } + + #[test] + fn flush_blocking_with_very_short_timeout() { + let (event_sender, _) = create_event_sender(); + let events_configuration = + create_events_configuration(event_sender, Duration::from_secs(100)); + let event_processor = + EventProcessorImpl::new(events_configuration).expect("failed to start ep"); + + let event_factory = EventFactory::new(true); + + // Send many events to increase the chance of timeout + for i in 0..100 { + let ctx = ContextBuilder::new(format!("user-{i}")) + .build() + .expect("Failed to create context"); + event_processor.send(event_factory.new_identify(ctx)); + } + + // Very short timeout may or may not complete - just verify it doesn't panic + let _result = event_processor.flush_blocking(Duration::from_nanos(1)); + + event_processor.close(); + } + + #[test] + fn flush_blocking_with_zero_timeout_waits() { + let (event_sender, event_rx) = create_event_sender(); + let events_configuration = + create_events_configuration(event_sender, Duration::from_secs(100)); + let event_processor = + EventProcessorImpl::new(events_configuration).expect("failed to start ep"); + + let context = ContextBuilder::new("foo") + .build() + .expect("Failed to create context"); + let event_factory = EventFactory::new(true); + event_processor.send(event_factory.new_identify(context)); + + let result = event_processor.flush_blocking(Duration::ZERO); + assert!( + result, + "flush_blocking with zero timeout should complete successfully" + ); + + event_processor.close(); + let events = event_rx.iter().collect::>(); + assert_eq!(events.len(), 1); + } + + #[test] + fn flush_blocking_with_no_events_completes_immediately() { + let (event_sender, _) = create_event_sender(); + let events_configuration = + create_events_configuration(event_sender, Duration::from_secs(100)); + let event_processor = + EventProcessorImpl::new(events_configuration).expect("failed to start ep"); + + let result = event_processor.flush_blocking(Duration::from_secs(1)); + assert!( + result, + "flush_blocking with no events should complete immediately" + ); + + event_processor.close(); + } + + #[test] + fn null_processor_flush_blocking_returns_true() { + let processor = NullEventProcessor::new(); + assert!(processor.flush_blocking(Duration::from_secs(1))); + assert!(processor.flush_blocking(Duration::ZERO)); + } + + #[test] + fn flush_blocking_fails_when_processor_closed() { + let (event_sender, _) = create_event_sender(); + let events_configuration = + create_events_configuration(event_sender, Duration::from_secs(100)); + let event_processor = + EventProcessorImpl::new(events_configuration).expect("failed to start ep"); + + event_processor.close(); + + let result = event_processor.flush_blocking(Duration::from_secs(1)); + assert!( + !result, + "flush_blocking should fail when processor is closed" + ); + } + + #[test] + fn flush_blocking_completes_on_recoverable_http_failure() { + use std::collections::HashSet; + use std::num::NonZeroUsize; + use std::sync::Arc; + + let event_sender = FailingEventSender::new(false); + let events_configuration = crate::events::EventsConfiguration { + capacity: 5, + event_sender: Arc::new(event_sender), + flush_interval: Duration::from_secs(100), + context_keys_capacity: NonZeroUsize::new(5).expect("5 > 0"), + context_keys_flush_interval: Duration::from_secs(100), + all_attributes_private: false, + private_attributes: HashSet::new(), + omit_anonymous_contexts: false, + }; + let event_processor = + EventProcessorImpl::new(events_configuration).expect("failed to start ep"); + + let context = ContextBuilder::new("foo") + .build() + .expect("Failed to create context"); + let event_factory = EventFactory::new(true); + event_processor.send(event_factory.new_identify(context)); + + // Even though HTTP fails, flush_blocking should complete (not hang) + let result = event_processor.flush_blocking(Duration::from_secs(5)); + assert!( + result, + "flush_blocking should complete even when HTTP send fails (recoverable)" + ); + + event_processor.close(); + } + + #[test] + fn flush_blocking_completes_on_unrecoverable_http_failure() { + use std::collections::HashSet; + use std::num::NonZeroUsize; + use std::sync::Arc; + + let event_sender = FailingEventSender::new(true); + let events_configuration = crate::events::EventsConfiguration { + capacity: 5, + event_sender: Arc::new(event_sender), + flush_interval: Duration::from_secs(100), + context_keys_capacity: NonZeroUsize::new(5).expect("5 > 0"), + context_keys_flush_interval: Duration::from_secs(100), + all_attributes_private: false, + private_attributes: HashSet::new(), + omit_anonymous_contexts: false, + }; + let event_processor = + EventProcessorImpl::new(events_configuration).expect("failed to start ep"); + + let context = ContextBuilder::new("foo") + .build() + .expect("Failed to create context"); + let event_factory = EventFactory::new(true); + event_processor.send(event_factory.new_identify(context)); + + // Even with must_shutdown=true, flush_blocking should complete (not hang) + let result = event_processor.flush_blocking(Duration::from_secs(5)); + assert!( + result, + "flush_blocking should complete even when HTTP send fails (unrecoverable)" + ); + + event_processor.close(); + } + + #[test] + fn flush_blocking_with_multiple_events_and_http_failures() { + use std::collections::HashSet; + use std::num::NonZeroUsize; + use std::sync::Arc; + + let event_sender = FailingEventSender::new(false); + let events_configuration = crate::events::EventsConfiguration { + capacity: 5, + event_sender: Arc::new(event_sender), + flush_interval: Duration::from_secs(100), + context_keys_capacity: NonZeroUsize::new(5).expect("5 > 0"), + context_keys_flush_interval: Duration::from_secs(100), + all_attributes_private: false, + private_attributes: HashSet::new(), + omit_anonymous_contexts: false, + }; + let event_processor = + EventProcessorImpl::new(events_configuration).expect("failed to start ep"); + + let event_factory = EventFactory::new(true); + + // Send multiple events + for i in 0..10 { + let ctx = ContextBuilder::new(format!("user-{i}")) + .build() + .expect("Failed to create context"); + event_processor.send(event_factory.new_identify(ctx)); + } + + // flush_blocking should complete even with multiple events and HTTP failures + let result = event_processor.flush_blocking(Duration::from_secs(5)); + assert!( + result, + "flush_blocking should complete with multiple events despite HTTP failures" + ); + + event_processor.close(); + } } diff --git a/launchdarkly-server-sdk/src/events/sender.rs b/launchdarkly-server-sdk/src/events/sender.rs index 9219f46..3af8532 100644 --- a/launchdarkly-server-sdk/src/events/sender.rs +++ b/launchdarkly-server-sdk/src/events/sender.rs @@ -24,6 +24,7 @@ pub struct EventSenderResult { pub(super) time_from_server: u128, pub(super) success: bool, pub(super) must_shutdown: bool, + pub(super) flush_signal: Option>, } pub trait EventSender: Send + Sync { @@ -31,6 +32,7 @@ pub trait EventSender: Send + Sync { &self, events: Vec, result_tx: Sender, + flush_signal: Option>, ) -> BoxFuture<'_, ()>; } @@ -84,6 +86,7 @@ impl EventSender for HttpEventSender { &self, events: Vec, result_tx: Sender, + flush_signal: Option>, ) -> BoxFuture<'_, ()> { Box::pin(async move { let uuid = Uuid::new_v4(); @@ -159,6 +162,7 @@ impl EventSender for HttpEventSender { success: false, time_from_server: 0, must_shutdown: false, + flush_signal, }) .unwrap(); return; @@ -170,6 +174,7 @@ impl EventSender for HttpEventSender { success: true, time_from_server: self.get_server_time_from_response(&response), must_shutdown: false, + flush_signal, }); return; } @@ -180,6 +185,7 @@ impl EventSender for HttpEventSender { success: false, time_from_server: 0, must_shutdown: true, + flush_signal, }) .unwrap(); return; @@ -191,6 +197,7 @@ impl EventSender for HttpEventSender { success: false, time_from_server: 0, must_shutdown: false, + flush_signal, }) .unwrap(); }) @@ -215,6 +222,7 @@ impl EventSender for InMemoryEventSender { &self, events: Vec, sender: Sender, + flush_signal: Option>, ) -> BoxFuture<()> { Box::pin(async move { for event in events { @@ -226,6 +234,7 @@ impl EventSender for InMemoryEventSender { time_from_server: 0, success: true, must_shutdown: true, + flush_signal, }) .unwrap(); }) @@ -266,7 +275,7 @@ mod tests { let (tx, rx) = bounded::(5); let event_sender = build_event_sender(server.url()); - event_sender.send_event_data(vec![], tx).await; + event_sender.send_event_data(vec![], tx, None).await; let sender_result = rx.recv().unwrap(); assert!(sender_result.success); @@ -286,7 +295,7 @@ mod tests { let (tx, rx) = bounded::(5); let event_sender = build_event_sender(server.url()); - event_sender.send_event_data(vec![], tx).await; + event_sender.send_event_data(vec![], tx, None).await; let sender_result = rx.recv().expect("Failed to receive sender_result"); assert!(!sender_result.success); @@ -306,7 +315,7 @@ mod tests { let (tx, rx) = bounded::(5); let event_sender = build_event_sender(server.url()); - event_sender.send_event_data(vec![], tx).await; + event_sender.send_event_data(vec![], tx, None).await; let sender_result = rx.recv().expect("Failed to receive sender_result"); assert!(!sender_result.success); @@ -332,7 +341,7 @@ mod tests { let (tx, rx) = bounded::(5); let event_sender = build_event_sender(server.url()); - event_sender.send_event_data(vec![], tx).await; + event_sender.send_event_data(vec![], tx, None).await; let sender_result = rx.recv().expect("Failed to receive sender_result"); assert!(sender_result.success); From f5af46af116314d080757d0fed422b274b43c8a9 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Thu, 19 Feb 2026 15:09:43 -0500 Subject: [PATCH 10/15] feat: Replace local http transport with launchdarkly-sdk-transport (#150) --- .github/actions/build-docs/action.yml | 2 +- .github/actions/ci/action.yml | 10 +- .github/workflows/ci.yml | 50 ++++ contract-tests/Cargo.toml | 4 +- contract-tests/src/client_entity.rs | 10 +- launchdarkly-server-sdk/Cargo.toml | 13 +- .../examples/custom_transport.rs | 9 +- launchdarkly-server-sdk/src/client.rs | 2 +- launchdarkly-server-sdk/src/config.rs | 17 +- launchdarkly-server-sdk/src/data_source.rs | 9 +- .../src/data_source_builders.rs | 68 +++-- .../src/events/processor_builders.rs | 36 ++- launchdarkly-server-sdk/src/events/sender.rs | 38 +-- .../src/feature_requester.rs | 14 +- .../src/feature_requester_builders.rs | 5 +- launchdarkly-server-sdk/src/lib.rs | 13 +- launchdarkly-server-sdk/src/reqwest.rs | 44 ++-- launchdarkly-server-sdk/src/transport.rs | 122 --------- .../src/transport_hyper.rs | 246 ------------------ 19 files changed, 213 insertions(+), 499 deletions(-) delete mode 100644 launchdarkly-server-sdk/src/transport.rs delete mode 100644 launchdarkly-server-sdk/src/transport_hyper.rs diff --git a/.github/actions/build-docs/action.yml b/.github/actions/build-docs/action.yml index 01685b5..480322e 100644 --- a/.github/actions/build-docs/action.yml +++ b/.github/actions/build-docs/action.yml @@ -6,4 +6,4 @@ runs: steps: - name: Build Documentation shell: bash - run: cargo doc --no-deps -p launchdarkly-server-sdk + run: cargo doc --no-deps --all-features -p launchdarkly-server-sdk diff --git a/.github/actions/ci/action.yml b/.github/actions/ci/action.yml index 45648a7..3040232 100644 --- a/.github/actions/ci/action.yml +++ b/.github/actions/ci/action.yml @@ -1,6 +1,12 @@ name: CI Workflow description: 'Shared CI workflow.' +inputs: + feature-flags: + description: 'Cargo feature flags to pass to test and clippy commands' + required: false + default: '' + runs: using: composite steps: @@ -10,8 +16,8 @@ runs: - name: Run tests shell: bash - run: cargo test -p launchdarkly-server-sdk + run: cargo test ${{ inputs.feature-flags }} -p launchdarkly-server-sdk - name: Run clippy checks shell: bash - run: cargo clippy -p launchdarkly-server-sdk -- -D warnings + run: cargo clippy ${{ inputs.feature-flags }} -p launchdarkly-server-sdk -- -D warnings diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e4880f..466d017 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,20 @@ on: jobs: ci-build: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + features: + - name: "default" + flags: "" + - name: "no-features" + flags: "--no-default-features" + - name: "hyper" + flags: "--no-default-features --features hyper" + - name: "hyper-rustls" + flags: "--no-default-features --features hyper-rustls" + + name: CI (${{ matrix.features.name }}) steps: - uses: actions/checkout@v4 @@ -28,6 +42,25 @@ jobs: rustup component add rustfmt clippy - uses: ./.github/actions/ci + with: + feature-flags: ${{ matrix.features.flags }} + + contract-tests: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get Rust version + id: rust-version + run: cat ./.github/variables/rust-versions.env >> $GITHUB_OUTPUT + + - name: Setup rust tooling + run: | + rustup override set ${{ steps.rust-version.outputs.target }} + rustup component add rustfmt clippy - name: "Run contract tests with hyper_rustls" uses: ./.github/actions/contract-tests @@ -41,6 +74,23 @@ jobs: tls_feature: "tls" token: ${{ secrets.GITHUB_TOKEN }} + build-docs: + runs-on: ubuntu-latest + name: Build Documentation (all features) + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get Rust version + id: rust-version + run: cat ./.github/variables/rust-versions.env >> $GITHUB_OUTPUT + + - name: Setup rust tooling + run: | + rustup override set ${{ steps.rust-version.outputs.target }} + - uses: ./.github/actions/build-docs musl-build: diff --git a/contract-tests/Cargo.toml b/contract-tests/Cargo.toml index 6b12a38..1f48685 100644 --- a/contract-tests/Cargo.toml +++ b/contract-tests/Cargo.toml @@ -9,8 +9,8 @@ license = "Apache-2.0" actix = "0.13.0" actix-web = "4.2.1" env_logger = "0.10.0" -# eventsource-client = { version = "0.16.0", default-features = false } -eventsource-client = { git = "https://github.com/launchdarkly/rust-eventsource-client", branch = "feat/hyper-as-feature" } +eventsource-client = { git = "https://github.com/launchdarkly/rust-eventsource-client.git", branch = "feat/hyper-as-feature" } +launchdarkly-sdk-transport = { version = "0.1.0" } log = "0.4.14" launchdarkly-server-sdk = { path = "../launchdarkly-server-sdk/", default-features = false, features = ["event-compression"]} serde = { version = "1.0.132", features = ["derive"] } diff --git a/contract-tests/src/client_entity.rs b/contract-tests/src/client_entity.rs index 1a72648..14dffed 100644 --- a/contract-tests/src/client_entity.rs +++ b/contract-tests/src/client_entity.rs @@ -38,10 +38,12 @@ impl ClientEntity { connector: HttpsConnector, ) -> Result { // Create fresh transports for this client to avoid shared connection pool issues - let transport = - launchdarkly_server_sdk::HyperTransport::new_with_connector(connector.clone()); - let streaming_https_transport = - eventsource_client::HyperTransport::builder().build_with_connector(connector.clone()); + let transport = launchdarkly_sdk_transport::HyperTransport::builder() + .build_with_connector(connector.clone()) + .map_err(|e| BuildError::InvalidConfig(e.to_string()))?; + let streaming_https_transport = launchdarkly_sdk_transport::HyperTransport::builder() + .build_with_connector(connector.clone()) + .map_err(|e| BuildError::InvalidConfig(e.to_string()))?; let mut config_builder = ConfigBuilder::new(&create_instance_params.configuration.credential); diff --git a/launchdarkly-server-sdk/Cargo.toml b/launchdarkly-server-sdk/Cargo.toml index 130cc8d..7f5b16d 100644 --- a/launchdarkly-server-sdk/Cargo.toml +++ b/launchdarkly-server-sdk/Cargo.toml @@ -20,8 +20,8 @@ features = ["event-compression"] chrono = "0.4.19" crossbeam-channel = "0.5.1" data-encoding = "2.3.2" -# eventsource-client = { version = "0.16.0", default-features = false } -eventsource-client = { git = "https://github.com/launchdarkly/rust-eventsource-client", default-features = false, branch = "feat/hyper-as-feature" } +eventsource-client = { git = "https://github.com/launchdarkly/rust-eventsource-client.git", branch = "feat/hyper-as-feature" } +launchdarkly-sdk-transport = { version = "0.1.0" } futures = "0.3.12" log = "0.4.14" lru = { version = "0.16.3", default-features = false } @@ -37,11 +37,6 @@ uuid = {version = "1.2.2", features = ["v4"] } http = "1.0" bytes = "1.11" bitflags = "2.4" -hyper = { version = "1.0", features = ["client", "http1", "http2"], optional = true } -hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2", "tokio"], optional = true } -http-body-util = { version = "0.1", optional = true } -hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "webpki-roots"], optional = true} -tower = { version = "0.4", optional = true } rand = "0.9" flate2 = { version = "1.0.35", optional = true } aws-lc-rs = "1.14.1" @@ -60,8 +55,8 @@ testing_logger = "0.1.1" [features] default = ["hyper-rustls"] -hyper = ["dep:hyper", "dep:hyper-util", "dep:http-body-util", "dep:tower", "eventsource-client/hyper"] -hyper-rustls = ["dep:hyper-rustls", "hyper", "eventsource-client/hyper-rustls"] +hyper = ["launchdarkly-sdk-transport/hyper", "eventsource-client/hyper"] +hyper-rustls = ["hyper", "launchdarkly-sdk-transport/hyper-rustls", "eventsource-client/hyper-rustls"] event-compression = ["flate2"] [[example]] diff --git a/launchdarkly-server-sdk/examples/custom_transport.rs b/launchdarkly-server-sdk/examples/custom_transport.rs index ecdb895..9867abf 100644 --- a/launchdarkly-server-sdk/examples/custom_transport.rs +++ b/launchdarkly-server-sdk/examples/custom_transport.rs @@ -1,8 +1,7 @@ use bytes::Bytes; use http::Request; -use launchdarkly_server_sdk::{ - ConfigBuilder, EventProcessorBuilder, HttpTransport, ResponseFuture, -}; +use launchdarkly_sdk_transport::{HttpTransport, ResponseFuture}; +use launchdarkly_server_sdk::{ConfigBuilder, EventProcessorBuilder}; use std::time::Instant; /// Example of a custom transport that wraps another transport and adds logging. @@ -21,7 +20,7 @@ impl LoggingTransport { } impl HttpTransport for LoggingTransport { - fn request(&self, request: Request) -> ResponseFuture { + fn request(&self, request: Request>) -> ResponseFuture { let method = request.method().clone(); let uri = request.uri().clone(); let start = Instant::now(); @@ -65,7 +64,7 @@ async fn main() -> Result<(), Box> { } // Create the base HTTPS transport - let base_transport = launchdarkly_server_sdk::HyperTransport::new_https(); + let base_transport = launchdarkly_sdk_transport::HyperTransport::new_https()?; // Wrap it with logging middleware let logging_transport = LoggingTransport::new(base_transport); diff --git a/launchdarkly-server-sdk/src/client.rs b/launchdarkly-server-sdk/src/client.rs index 0646111..39af0d8 100644 --- a/launchdarkly-server-sdk/src/client.rs +++ b/launchdarkly-server-sdk/src/client.rs @@ -2722,7 +2722,7 @@ mod tests { .daemon_mode(daemon_mode) .data_source(MockDataSourceBuilder::new().data_source(updates)) .event_processor( - EventProcessorBuilder::::new() + EventProcessorBuilder::::new() .event_sender(Arc::new(event_sender)), ) .build() diff --git a/launchdarkly-server-sdk/src/config.rs b/launchdarkly-server-sdk/src/config.rs index 0c025cd..07bd8a7 100644 --- a/launchdarkly-server-sdk/src/config.rs +++ b/launchdarkly-server-sdk/src/config.rs @@ -6,7 +6,6 @@ use crate::events::processor_builders::{ }; use crate::stores::store_builders::{DataStoreFactory, InMemoryDataStoreBuilder}; use crate::{ServiceEndpointsBuilder, StreamingDataSourceBuilder}; -use eventsource_client as es; use std::borrow::Borrow; @@ -303,7 +302,13 @@ impl ConfigBuilder { Some(builder) => Ok(builder), #[cfg(feature = "hyper-rustls")] None => { - let transport = es::HyperTransport::new_https(); + let transport = launchdarkly_sdk_transport::HyperTransport::new_https() + .map_err(|e| { + BuildError::InvalidConfig(format!( + "failed to create default transport: {}", + e + )) + })?; let mut builder = StreamingDataSourceBuilder::new(); builder.transport(transport); Ok(Box::new(builder)) @@ -325,7 +330,13 @@ impl ConfigBuilder { Some(builder) => Ok(builder), #[cfg(feature = "hyper-rustls")] None => { - let transport = crate::HyperTransport::new_https(); + let transport = launchdarkly_sdk_transport::HyperTransport::new_https() + .map_err(|e| { + BuildError::InvalidConfig(format!( + "failed to create default transport: {}", + e + )) + })?; let mut builder = EventProcessorBuilder::new(); builder.transport(transport); Ok(Box::new(builder)) diff --git a/launchdarkly-server-sdk/src/data_source.rs b/launchdarkly-server-sdk/src/data_source.rs index f1c8e23..47b5c88 100644 --- a/launchdarkly-server-sdk/src/data_source.rs +++ b/launchdarkly-server-sdk/src/data_source.rs @@ -70,7 +70,7 @@ pub struct StreamingDataSource { impl StreamingDataSource { #[allow(clippy::result_large_err)] - pub fn new( + pub fn new( base_url: &str, sdk_key: &str, initial_reconnect_delay: Duration, @@ -375,7 +375,6 @@ mod tests { use super::{DataSource, PollingDataSource, StreamingDataSource}; use crate::feature_requester_builders::HttpFeatureRequesterBuilder; use crate::{stores::store::InMemoryDataStore, LAUNCHDARKLY_TAGS_HEADER}; - use eventsource_client as es; #[test_case(Some("application-id/abc:application-sha/xyz".into()), "application-id/abc:application-sha/xyz")] #[test_case(None, Matcher::Missing)] @@ -402,7 +401,8 @@ mod tests { "sdk-key", Duration::from_secs(0), &tag, - es::HyperTransport::new(), + launchdarkly_sdk_transport::HyperTransport::new() + .expect("Failed to create streaming data source"), ) .unwrap(); @@ -453,7 +453,8 @@ mod tests { let (shutdown_tx, _) = broadcast::channel::<()>(1); let initialized = Arc::new(AtomicBool::new(false)); - let transport = crate::HyperTransport::new(); + let transport = launchdarkly_sdk_transport::HyperTransport::new() + .expect("Failed to create transport for polling data source"); let hyper_builder = HttpFeatureRequesterBuilder::new(&server.url(), "sdk-key", transport); let polling = PollingDataSource::new( diff --git a/launchdarkly-server-sdk/src/data_source_builders.rs b/launchdarkly-server-sdk/src/data_source_builders.rs index 39ed7d7..b060111 100644 --- a/launchdarkly-server-sdk/src/data_source_builders.rs +++ b/launchdarkly-server-sdk/src/data_source_builders.rs @@ -1,8 +1,7 @@ use super::service_endpoints; use crate::data_source::{DataSource, NullDataSource, PollingDataSource, StreamingDataSource}; use crate::feature_requester_builders::{FeatureRequesterFactory, HttpFeatureRequesterBuilder}; -use crate::transport::HttpTransport; -use eventsource_client as es; +use launchdarkly_sdk_transport::HttpTransport; use std::sync::{Arc, Mutex}; use std::time::Duration; use thiserror::Error; @@ -45,20 +44,20 @@ pub trait DataSourceFactory { /// Adjust the initial reconnect delay. /// ``` /// # use launchdarkly_server_sdk::{StreamingDataSourceBuilder, ConfigBuilder}; -/// # use eventsource_client as es; +/// # use launchdarkly_sdk_transport::HyperTransport; /// # use std::time::Duration; /// # fn main() { -/// ConfigBuilder::new("sdk-key").data_source(StreamingDataSourceBuilder::::new() +/// ConfigBuilder::new("sdk-key").data_source(StreamingDataSourceBuilder::::new() /// .initial_reconnect_delay(Duration::from_secs(10))); /// # } /// ``` #[derive(Clone)] -pub struct StreamingDataSourceBuilder { +pub struct StreamingDataSourceBuilder { initial_reconnect_delay: Duration, transport: Option, } -impl StreamingDataSourceBuilder { +impl StreamingDataSourceBuilder { /// Create a new instance of the [StreamingDataSourceBuilder] with default values. pub fn new() -> Self { Self { @@ -83,7 +82,9 @@ impl StreamingDataSourceBuilder { } } -impl DataSourceFactory for StreamingDataSourceBuilder { +impl DataSourceFactory + for StreamingDataSourceBuilder +{ fn build( &self, endpoints: &service_endpoints::ServiceEndpoints, @@ -92,13 +93,21 @@ impl DataSourceFactory for StreamingDataSourceBuilder { ) -> Result, BuildError> { let data_source_result = match &self.transport { #[cfg(feature = "hyper-rustls")] - None => Ok(StreamingDataSource::new( - endpoints.streaming_base_url(), - sdk_key, - self.initial_reconnect_delay, - &tags, - es::HyperTransport::new_https(), - )), + None => { + let transport = + launchdarkly_sdk_transport::HyperTransport::new_https().map_err(|e| { + BuildError::InvalidConfig(format!( + "failed to create default https transport: {e:?}" + )) + })?; + Ok(StreamingDataSource::new( + endpoints.streaming_base_url(), + sdk_key, + self.initial_reconnect_delay, + &tags, + transport, + )) + } #[cfg(not(feature = "hyper-rustls"))] None => Err(BuildError::InvalidConfig( "https connector required when rustls is disabled".into(), @@ -121,7 +130,7 @@ impl DataSourceFactory for StreamingDataSourceBuilder { } } -impl Default for StreamingDataSourceBuilder { +impl Default for StreamingDataSourceBuilder { fn default() -> Self { StreamingDataSourceBuilder::new() } @@ -171,7 +180,8 @@ impl Default for NullDataSourceBuilder { /// /// Adjust the initial reconnect delay. /// ``` -/// # use launchdarkly_server_sdk::{PollingDataSourceBuilder, ConfigBuilder, HyperTransport}; +/// # use launchdarkly_server_sdk::{PollingDataSourceBuilder, ConfigBuilder}; +/// # use launchdarkly_sdk_transport::HyperTransport; /// # use std::time::Duration; /// # fn main() { /// ConfigBuilder::new("sdk-key").data_source(PollingDataSourceBuilder::::new() @@ -179,7 +189,7 @@ impl Default for NullDataSourceBuilder { /// # } /// ``` #[derive(Clone)] -pub struct PollingDataSourceBuilder { +pub struct PollingDataSourceBuilder { poll_interval: Duration, transport: Option, } @@ -199,7 +209,8 @@ pub struct PollingDataSourceBuilder { /// /// Adjust the poll interval. /// ``` -/// # use launchdarkly_server_sdk::{PollingDataSourceBuilder, ConfigBuilder, HyperTransport}; +/// # use launchdarkly_server_sdk::{PollingDataSourceBuilder, ConfigBuilder}; +/// # use launchdarkly_sdk_transport::HyperTransport; /// # use std::time::Duration; /// # fn main() { /// ConfigBuilder::new("sdk-key").data_source(PollingDataSourceBuilder::::new() @@ -245,7 +256,12 @@ impl DataSourceFactory for PollingDataSourceBuilder { match &self.transport { #[cfg(feature = "hyper-rustls")] None => { - let transport = crate::HyperTransport::new_https(); + let transport = launchdarkly_sdk_transport::HyperTransport::new_https() + .map_err(|e| { + BuildError::InvalidConfig(format!( + "failed to create default https transport: {e:?}" + )) + })?; Ok(Box::new(HttpFeatureRequesterBuilder::new( endpoints.polling_base_url(), @@ -323,13 +339,14 @@ impl DataSourceFactory for MockDataSourceBuilder { #[cfg(test)] mod tests { - use eventsource_client::{HyperTransport, ResponseFuture}; + use bytes::Bytes; + use launchdarkly_sdk_transport::{HyperTransport, Request, ResponseFuture}; use super::*; #[test] fn default_stream_builder_has_correct_defaults() { - let builder: StreamingDataSourceBuilder = + let builder: StreamingDataSourceBuilder = StreamingDataSourceBuilder::new(); assert_eq!( @@ -343,11 +360,8 @@ mod tests { #[derive(Debug, Clone)] struct TestTransport; - impl es::HttpTransport for TestTransport { - fn request( - &self, - _request: eventsource_client::Request>, - ) -> ResponseFuture { + impl launchdarkly_sdk_transport::HttpTransport for TestTransport { + fn request(&self, _request: Request>) -> ResponseFuture { // this won't be called during the test unreachable!(); } @@ -366,7 +380,7 @@ mod tests { #[test] fn default_polling_builder_has_correct_defaults() { - let builder = PollingDataSourceBuilder::::new(); + let builder = PollingDataSourceBuilder::::new(); assert_eq!(builder.poll_interval, MINIMUM_POLL_INTERVAL,); } diff --git a/launchdarkly-server-sdk/src/events/processor_builders.rs b/launchdarkly-server-sdk/src/events/processor_builders.rs index 5aa1cf4..1d4ebf9 100644 --- a/launchdarkly-server-sdk/src/events/processor_builders.rs +++ b/launchdarkly-server-sdk/src/events/processor_builders.rs @@ -9,8 +9,8 @@ use launchdarkly_server_sdk_evaluation::Reference; use thiserror::Error; use crate::events::sender::HttpEventSender; -use crate::transport::HttpTransport; use crate::{service_endpoints, LAUNCHDARKLY_TAGS_HEADER}; +use launchdarkly_sdk_transport::HttpTransport; use super::processor::{ EventProcessor, EventProcessorError, EventProcessorImpl, NullEventProcessor, @@ -60,7 +60,8 @@ pub trait EventProcessorFactory { /// /// Adjust the flush interval /// ``` -/// # use launchdarkly_server_sdk::{EventProcessorBuilder, ConfigBuilder, HyperTransport}; +/// # use launchdarkly_server_sdk::{EventProcessorBuilder, ConfigBuilder}; +/// # use launchdarkly_sdk_transport::HyperTransport; /// # use std::time::Duration; /// # fn main() { /// ConfigBuilder::new("sdk-key").event_processor(EventProcessorBuilder::::new() @@ -68,7 +69,7 @@ pub trait EventProcessorFactory { /// # } /// ``` #[derive(Clone)] -pub struct EventProcessorBuilder { +pub struct EventProcessorBuilder { capacity: usize, flush_interval: Duration, context_keys_capacity: NonZeroUsize, @@ -112,7 +113,12 @@ impl EventProcessorFactory for EventProcessorBuilder { } else { #[cfg(feature = "hyper-rustls")] { - let transport = crate::HyperTransport::new_https(); + let transport = launchdarkly_sdk_transport::HyperTransport::new_https().map_err(|e| { + BuildError::InvalidConfig(format!( + "failed to create default https transport: {}", + e + )) + })?; Ok(Arc::new(HttpEventSender::new( transport, Uri::from_str(url_string.as_str()).unwrap(), @@ -317,28 +323,31 @@ mod tests { #[test] fn default_builder_has_correct_defaults() { - let builder = EventProcessorBuilder::::new(); + let builder = EventProcessorBuilder::::new(); assert_eq!(builder.capacity, DEFAULT_EVENT_CAPACITY); assert_eq!(builder.flush_interval, DEFAULT_FLUSH_POLL_INTERVAL); } #[test] fn capacity_can_be_adjusted() { - let mut builder = EventProcessorBuilder::::new(); + let mut builder = + EventProcessorBuilder::::new(); builder.capacity(1234); assert_eq!(builder.capacity, 1234); } #[test] fn flush_interval_can_be_adjusted() { - let mut builder = EventProcessorBuilder::::new(); + let mut builder = + EventProcessorBuilder::::new(); builder.flush_interval(Duration::from_secs(1234)); assert_eq!(builder.flush_interval, Duration::from_secs(1234)); } #[test] fn context_keys_capacity_can_be_adjusted() { - let mut builder = EventProcessorBuilder::::new(); + let mut builder = + EventProcessorBuilder::::new(); let cap = NonZeroUsize::new(1234).expect("1234 > 0"); builder.context_keys_capacity(cap); assert_eq!(builder.context_keys_capacity, cap); @@ -346,7 +355,8 @@ mod tests { #[test] fn context_keys_flush_interval_can_be_adjusted() { - let mut builder = EventProcessorBuilder::::new(); + let mut builder = + EventProcessorBuilder::::new(); builder.context_keys_flush_interval(Duration::from_secs(1000)); assert_eq!( builder.context_keys_flush_interval, @@ -356,7 +366,8 @@ mod tests { #[test] fn all_attribute_private_can_be_adjusted() { - let mut builder = EventProcessorBuilder::::new(); + let mut builder = + EventProcessorBuilder::::new(); assert!(!builder.all_attributes_private); builder.all_attributes_private(true); @@ -365,7 +376,8 @@ mod tests { #[test] fn attribte_names_can_be_adjusted() { - let mut builder = EventProcessorBuilder::::new(); + let mut builder = + EventProcessorBuilder::::new(); assert!(builder.private_attributes.is_empty()); builder.private_attributes(hashset!["name"]); @@ -390,7 +402,7 @@ mod tests { .build() .expect("Service endpoints failed to be created"); - let builder = EventProcessorBuilder::::new(); + let builder = EventProcessorBuilder::::new(); let processor = builder .build(&service_endpoints, "sdk-key", tag) .expect("Processor failed to build"); diff --git a/launchdarkly-server-sdk/src/events/sender.rs b/launchdarkly-server-sdk/src/events/sender.rs index 3af8532..162e84c 100644 --- a/launchdarkly-server-sdk/src/events/sender.rs +++ b/launchdarkly-server-sdk/src/events/sender.rs @@ -1,9 +1,10 @@ use crate::{ - reqwest::is_http_error_recoverable, transport::HttpTransport, LAUNCHDARKLY_EVENT_SCHEMA_HEADER, + reqwest::is_http_error_recoverable, LAUNCHDARKLY_EVENT_SCHEMA_HEADER, LAUNCHDARKLY_PAYLOAD_ID_HEADER, }; use chrono::DateTime; use crossbeam_channel::Sender; +use launchdarkly_sdk_transport::HttpTransport; use std::collections::HashMap; #[cfg(feature = "event-compression")] @@ -146,7 +147,7 @@ impl EventSender for HttpEventSender { // Create request with Bytes body for transport let body_bytes = Bytes::from(payload.clone()); - let request = request_builder.body(body_bytes).unwrap(); + let request = request_builder.body(Some(body_bytes)).unwrap(); let result = self.transport.request(request).await; @@ -223,7 +224,7 @@ impl EventSender for InMemoryEventSender { events: Vec, sender: Sender, flush_signal: Option>, - ) -> BoxFuture<()> { + ) -> BoxFuture<'_, ()> { Box::pin(async move { for event in events { self.event_tx.send(event).unwrap(); @@ -248,18 +249,18 @@ mod tests { use std::str::FromStr; use test_case::test_case; - #[test_case(hyper::StatusCode::CONTINUE, true)] - #[test_case(hyper::StatusCode::OK, true)] - #[test_case(hyper::StatusCode::MULTIPLE_CHOICES, true)] - #[test_case(hyper::StatusCode::BAD_REQUEST, true)] - #[test_case(hyper::StatusCode::UNAUTHORIZED, false)] - #[test_case(hyper::StatusCode::REQUEST_TIMEOUT, true)] - #[test_case(hyper::StatusCode::CONFLICT, false)] - #[test_case(hyper::StatusCode::TOO_MANY_REQUESTS, true)] - #[test_case(hyper::StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE, false)] - #[test_case(hyper::StatusCode::INTERNAL_SERVER_ERROR, true)] - fn can_determine_recoverable_errors(status: hyper::StatusCode, is_recoverable: bool) { - assert_eq!(is_recoverable, is_http_error_recoverable(status.as_u16())); + #[test_case(100, true; "100 CONTINUE is recoverable")] + #[test_case(200, true; "200 OK is recoverable")] + #[test_case(300, true; "300 MULTIPLE_CHOICES is recoverable")] + #[test_case(400, true; "400 BAD_REQUEST is recoverable")] + #[test_case(401, false; "401 UNAUTHORIZED is not recoverable")] + #[test_case(408, true; "408 REQUEST_TIMEOUT is recoverable")] + #[test_case(409, false; "409 CONFLICT is not recoverable")] + #[test_case(429, true; "429 TOO_MANY_REQUESTS is recoverable")] + #[test_case(431, false; "431 REQUEST_HEADER_FIELDS_TOO_LARGE is not recoverable")] + #[test_case(500, true; "500 INTERNAL_SERVER_ERROR is recoverable")] + fn can_determine_recoverable_errors(status: u16, is_recoverable: bool) { + assert_eq!(is_recoverable, is_http_error_recoverable(status)); } #[tokio::test] @@ -349,11 +350,14 @@ mod tests { assert_eq!(sender_result.time_from_server, 1234567890000); } - fn build_event_sender(url: String) -> HttpEventSender { + fn build_event_sender( + url: String, + ) -> HttpEventSender { let url = format!("{}/bulk", &url); let url = http::Uri::from_str(&url).expect("Failed parsing the mock server url"); - let transport = crate::HyperTransport::new(); + let transport = launchdarkly_sdk_transport::HyperTransport::new() + .expect("Failed to create HyperTransport"); HttpEventSender::new( transport, url, diff --git a/launchdarkly-server-sdk/src/feature_requester.rs b/launchdarkly-server-sdk/src/feature_requester.rs index 358bcdb..2c65f5e 100644 --- a/launchdarkly-server-sdk/src/feature_requester.rs +++ b/launchdarkly-server-sdk/src/feature_requester.rs @@ -1,8 +1,8 @@ use crate::reqwest::is_http_error_recoverable; -use crate::transport::HttpTransport; use bytes::Bytes; use futures::future::BoxFuture; use futures::stream::StreamExt; +use launchdarkly_sdk_transport::HttpTransport; use std::collections::HashMap; use super::stores::store_types::AllData; @@ -72,7 +72,7 @@ impl FeatureRequester for HttpFeatureRequester { } // Create empty body for GET request - let request = request_builder.body(Bytes::new()).unwrap(); + let request = request_builder.body(Some(Bytes::new())).unwrap(); let result = transport.request(request).await; @@ -86,7 +86,8 @@ impl FeatureRequester for HttpFeatureRequester { } }; - if response.status() == hyper::StatusCode::NOT_MODIFIED && cache.is_some() { + // 304 NOT MODIFIED + if response.status() == 304 && cache.is_some() { if let Some(entry) = cache { return Ok(entry.0); } @@ -246,9 +247,12 @@ mod tests { } } - fn build_feature_requester(url: String) -> HttpFeatureRequester { + fn build_feature_requester( + url: String, + ) -> HttpFeatureRequester { let url = http::Uri::from_str(&url).expect("Failed parsing the mock server url"); - let transport = crate::HyperTransport::new(); + let transport = launchdarkly_sdk_transport::HyperTransport::new() + .expect("Failed to create HyperTransport"); HttpFeatureRequester::new( transport, diff --git a/launchdarkly-server-sdk/src/feature_requester_builders.rs b/launchdarkly-server-sdk/src/feature_requester_builders.rs index 448fbd0..3c29f40 100644 --- a/launchdarkly-server-sdk/src/feature_requester_builders.rs +++ b/launchdarkly-server-sdk/src/feature_requester_builders.rs @@ -1,7 +1,7 @@ use crate::feature_requester::{FeatureRequester, HttpFeatureRequester}; -use crate::transport::HttpTransport; use crate::LAUNCHDARKLY_TAGS_HEADER; use http::Uri; +use launchdarkly_sdk_transport::HttpTransport; use std::collections::HashMap; use std::str::FromStr; use thiserror::Error; @@ -68,7 +68,8 @@ mod tests { #[test] fn factory_handles_url_parsing_failure() { - let transport = crate::HyperTransport::new(); + let transport = + launchdarkly_sdk_transport::HyperTransport::new().expect("Failed to create transport"); let builder = HttpFeatureRequesterBuilder::new( "This is clearly not a valid URL", "sdk-key", diff --git a/launchdarkly-server-sdk/src/lib.rs b/launchdarkly-server-sdk/src/lib.rs index ccfa7a8..7dd7b1d 100644 --- a/launchdarkly-server-sdk/src/lib.rs +++ b/launchdarkly-server-sdk/src/lib.rs @@ -16,12 +16,12 @@ extern crate log; #[macro_use] extern crate serde_json; +use http::HeaderValue; pub use launchdarkly_server_sdk_evaluation::Error as EvalError; pub use launchdarkly_server_sdk_evaluation::{ AttributeValue, Context, ContextBuilder, Detail, FlagValue, Kind, MultiContextBuilder, Reason, Reference, }; -use http::HeaderValue; use std::sync::LazyLock; pub use client::Client; @@ -53,11 +53,6 @@ pub use stores::persistent_store_builders::{ pub use stores::store_types::{AllData, DataKind, SerializedItem, StorageItem}; pub use version::version_string; -// Re-export transport types -pub use transport::{HttpTransport, ResponseFuture, TransportError}; -#[cfg(feature = "hyper")] -pub use transport_hyper::HyperTransport; - mod client; mod config; mod data_source; @@ -72,9 +67,6 @@ mod sampler; mod service_endpoints; mod stores; mod test_common; -mod transport; -#[cfg(feature = "hyper")] -mod transport_hyper; mod version; static LAUNCHDARKLY_EVENT_SCHEMA_HEADER: &str = "x-launchdarkly-event-schema"; @@ -85,8 +77,7 @@ static CURRENT_EVENT_SCHEMA: &str = "4"; static USER_AGENT: LazyLock = LazyLock::new(|| format!("RustServerClient/{}", version_string())); -static EMPTY_HEADER: LazyLock = - LazyLock::new(|| HeaderValue::from_static("")); +static EMPTY_HEADER: LazyLock = LazyLock::new(|| HeaderValue::from_static("")); #[cfg(test)] mod tests { diff --git a/launchdarkly-server-sdk/src/reqwest.rs b/launchdarkly-server-sdk/src/reqwest.rs index 9fcc2b9..8c0d69e 100644 --- a/launchdarkly-server-sdk/src/reqwest.rs +++ b/launchdarkly-server-sdk/src/reqwest.rs @@ -1,25 +1,17 @@ -use hyper::StatusCode; - pub fn is_http_error_recoverable(status: u16) -> bool { - if let Ok(status) = StatusCode::from_u16(status) { - if !status.is_client_error() { - return true; - } - - return matches!( - status, - StatusCode::BAD_REQUEST | StatusCode::REQUEST_TIMEOUT | StatusCode::TOO_MANY_REQUESTS - ); + if !(400..500).contains(&status) { + return true; } - warn!("Unable to determine if status code is recoverable"); - false + matches!( + status, + 400 | 408 | 429 // BAD_REQUEST | REQUEST_TIMEOUT | TOO_MANY_REQUESTS + ) } #[cfg(test)] mod tests { use super::*; - use hyper::StatusCode; use test_case::test_case; #[test_case("130.65331632653061", 130.65331632653061)] @@ -30,17 +22,17 @@ mod tests { assert_eq!(expected, parsed); } - #[test_case(StatusCode::CONTINUE, true)] - #[test_case(StatusCode::OK, true)] - #[test_case(StatusCode::MULTIPLE_CHOICES, true)] - #[test_case(StatusCode::BAD_REQUEST, true)] - #[test_case(StatusCode::UNAUTHORIZED, false)] - #[test_case(StatusCode::REQUEST_TIMEOUT, true)] - #[test_case(StatusCode::CONFLICT, false)] - #[test_case(StatusCode::TOO_MANY_REQUESTS, true)] - #[test_case(StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE, false)] - #[test_case(StatusCode::INTERNAL_SERVER_ERROR, true)] - fn can_determine_recoverable_errors(status: StatusCode, is_recoverable: bool) { - assert_eq!(is_recoverable, is_http_error_recoverable(status.as_u16())); + #[test_case(100, true; "CONTINUE_STATUS")] + #[test_case(200, true; "OK")] + #[test_case(300, true; "MULTIPLE_CHOICES")] + #[test_case(400, true; "BAD_REQUEST")] + #[test_case(401, false; "UNAUTHORIZED")] + #[test_case(408, true; "REQUEST_TIMEOUT")] + #[test_case(409, false; "CONFLICT")] + #[test_case(429, true; "TOO_MANY_REQUESTS")] + #[test_case(431, false; "REQUEST_HEADER_FIELDS_TOO_LARGE")] + #[test_case(500, true; "INTERNAL_SERVER_ERROR")] + fn can_determine_recoverable_errors(status: u16, is_recoverable: bool) { + assert_eq!(is_recoverable, is_http_error_recoverable(status)); } } diff --git a/launchdarkly-server-sdk/src/transport.rs b/launchdarkly-server-sdk/src/transport.rs deleted file mode 100644 index 92e9900..0000000 --- a/launchdarkly-server-sdk/src/transport.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! HTTP transport abstraction for LaunchDarkly SDK -//! -//! This module defines the [`HttpTransport`] trait which allows users to plug in -//! their own HTTP client implementation (hyper, reqwest, or custom). - -use bytes::Bytes; -use futures::Stream; -use std::error::Error as StdError; -use std::fmt; -use std::future::Future; -use std::pin::Pin; - -// Re-export http crate types for convenience -pub use http::{Request, Response}; - -/// A pinned, boxed stream of bytes returned by HTTP transports. -/// -/// This represents the streaming response body from an HTTP request. -pub type ByteStream = Pin> + Send + Sync>>; - -/// A pinned, boxed future for an HTTP response. -/// -/// This represents the future returned by [`HttpTransport::request`]. -pub type ResponseFuture = - Pin, TransportError>> + Send + Sync>>; - -/// Error type for HTTP transport operations. -/// -/// This wraps transport-specific errors (network failures, timeouts, etc.) in a -/// common error type that the SDK can handle uniformly. -#[derive(Debug)] -pub struct TransportError { - inner: Box, -} - -impl TransportError { - /// Create a new transport error from any error type. - pub fn new(err: impl StdError + Send + Sync + 'static) -> Self { - Self { - inner: Box::new(err), - } - } - - /// Get a reference to the inner error. - pub fn inner(&self) -> &(dyn StdError + Send + Sync + 'static) { - &*self.inner - } -} - -impl fmt::Display for TransportError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "transport error: {}", self.inner) - } -} - -impl StdError for TransportError { - fn source(&self) -> Option<&(dyn StdError + 'static)> { - Some(&*self.inner) - } -} - -/// Trait for pluggable HTTP transport implementations. -/// -/// Implement this trait to provide HTTP request/response functionality for the -/// SDK. The transport is responsible for: -/// - Establishing HTTP connections (with TLS if needed) -/// - Sending HTTP requests -/// - Returning streaming HTTP responses -/// - Handling timeouts (if desired) -/// -/// The SDK normally uses [`crate::HyperTransport`] as the default implementation, -/// but you can provide your own implementation for custom requirements such as: -/// - Using a different HTTP client library (reqwest, custom, etc.) -/// - Adding request/response logging or metrics -/// - Implementing custom retry logic -/// - Using a proxy or custom TLS configuration -/// -/// # Example -/// -/// ```no_run -/// use launchdarkly_server_sdk::{HttpTransport, ResponseFuture, TransportError}; -/// use bytes::Bytes; -/// use http::{Request, Response}; -/// -/// #[derive(Clone)] -/// struct LoggingTransport { -/// inner: T, -/// } -/// -/// impl HttpTransport for LoggingTransport { -/// fn request(&self, request: Request) -> ResponseFuture { -/// println!("Making request to: {}", request.uri()); -/// self.inner.request(request) -/// } -/// } -/// ``` -pub trait HttpTransport: Clone + Send + Sync + 'static { - /// Execute an HTTP request and return a streaming response. - /// - /// # Arguments - /// - /// * `request` - The HTTP request to execute. The body type is `Bytes` - /// to support both binary content and empty bodies. Use `Bytes::new()` - /// for requests with no body (e.g., GET requests). - /// - /// # Returns - /// - /// A future that resolves to an HTTP response with a streaming body, or a - /// transport error if the request fails. - /// - /// The response includes: - /// - Status code - /// - Response headers - /// - A stream of body bytes - /// - /// # Notes - /// - /// - The transport should NOT follow redirects - the SDK handles this when needed - /// - The transport should NOT retry requests - the SDK handles this - /// - The transport MAY implement timeouts as desired - fn request(&self, request: Request) -> ResponseFuture; -} diff --git a/launchdarkly-server-sdk/src/transport_hyper.rs b/launchdarkly-server-sdk/src/transport_hyper.rs deleted file mode 100644 index d73348e..0000000 --- a/launchdarkly-server-sdk/src/transport_hyper.rs +++ /dev/null @@ -1,246 +0,0 @@ -//! Hyper v1 transport implementation for LaunchDarkly SDK -//! -//! This module provides a production-ready [`HyperTransport`] implementation that -//! integrates hyper v1 with the LaunchDarkly SDK. - -use crate::transport::{ByteStream, HttpTransport, ResponseFuture, TransportError}; -use bytes::Bytes; -use http::{Request, Response}; -use http_body_util::{combinators::BoxBody, BodyExt, Empty, Full}; -use hyper::body::Incoming; -use hyper_util::client::legacy::Client as HyperClient; -use hyper_util::rt::TokioExecutor; - -/// A transport implementation using hyper v1.x -/// -/// This struct wraps a hyper client and implements the [`HttpTransport`] trait -/// for use with the LaunchDarkly SDK. -/// -/// # Default Configuration -/// -/// By default, `HyperTransport` uses: -/// - HTTP-only connector (no TLS) -/// - Both HTTP/1.1 and HTTP/2 protocol support -/// - No timeout configuration -/// -/// For HTTPS support, use [`HyperTransport::new_https()`] (requires the `rustls` feature) -/// or provide your own connector with [`HyperTransport::new_with_connector()`]. -/// -/// # Example -/// -/// ```ignore -/// use launchdarkly_server_sdk::{HyperTransport, ConfigBuilder, EventProcessorBuilder}; -/// -/// # #[cfg(feature = "hyper-rustls")] -/// # { -/// // Use default HTTPS transport -/// let transport = HyperTransport::new_https(); -/// -/// let config = ConfigBuilder::new("sdk-key") -/// .event_processor(EventProcessorBuilder::new().transport(transport.clone())) -/// .build(); -/// # } -/// ``` -#[derive(Clone)] -pub struct HyperTransport { - client: HyperClient>>, -} - -impl HyperTransport { - /// Create a new HyperTransport with default HTTP connector and no timeouts - /// - /// This creates a basic HTTP-only client that supports both HTTP/1 and HTTP/2. - /// For HTTPS support, use [`HyperTransport::new_https()`] instead. - /// - /// # Example - /// - /// ``` - /// use launchdarkly_server_sdk::HyperTransport; - /// - /// let transport = HyperTransport::new(); - /// ``` - pub fn new() -> Self { - let connector = hyper_util::client::legacy::connect::HttpConnector::new(); - let client = HyperClient::builder(TokioExecutor::new()).build(connector); - Self { client } - } - - /// Create a new HyperTransport with HTTPS support using rustls - /// - /// This creates an HTTPS client that supports both HTTP/1 and HTTP/2 protocols - /// with native certificate verification. - /// - /// This method is only available when the `rustls` feature is enabled. - /// - /// # Example - /// - /// ```no_run - /// # #[cfg(feature = "hyper-rustls")] - /// # { - /// use launchdarkly_server_sdk::HyperTransport; - /// - /// let transport = HyperTransport::new_https(); - /// # } - /// ``` - #[cfg(feature = "hyper-rustls")] - pub fn new_https() -> HyperTransport< - hyper_rustls::HttpsConnector, - > { - use hyper_rustls::HttpsConnectorBuilder; - - let connector = HttpsConnectorBuilder::new() - .with_webpki_roots() - .https_or_http() - .enable_http1() - .enable_http2() - .build(); - - let client = HyperClient::builder(TokioExecutor::new()).build(connector); - HyperTransport { client } - } -} - -impl HyperTransport { - /// Create a new HyperTransport with a custom connector - /// - /// This allows you to provide your own connector implementation, which is useful for: - /// - Custom TLS configuration - /// - Proxy support - /// - Connection pooling customization - /// - Custom DNS resolution - /// - /// # Example - /// - /// ```no_run - /// use launchdarkly_server_sdk::HyperTransport; - /// use hyper_util::client::legacy::connect::HttpConnector; - /// - /// let mut connector = HttpConnector::new(); - /// connector.set_nodelay(true); - /// - /// let transport = HyperTransport::new_with_connector(connector); - /// ``` - pub fn new_with_connector(connector: C) -> Self - where - C: hyper_util::client::legacy::connect::Connect + Clone + Send + Sync + 'static, - { - let client = HyperClient::builder(TokioExecutor::new()).build(connector); - Self { client } - } -} - -impl Default for HyperTransport { - fn default() -> Self { - Self::new() - } -} - -impl HttpTransport for HyperTransport -where - C: hyper_util::client::legacy::connect::Connect + Clone + Send + Sync + 'static, -{ - fn request(&self, request: Request) -> ResponseFuture { - let (parts, body) = request.into_parts(); - - // Convert Bytes to BoxBody for hyper - let boxed_body: BoxBody> = - if body.is_empty() { - // Use Empty for requests with no body (e.g., GET requests) - Empty::::new() - .map_err(|e| Box::new(e) as Box) - .boxed() - } else { - // Use Full for requests with a body - Full::new(body) - .map_err(|e| Box::new(e) as Box) - .boxed() - }; - - let hyper_req = hyper::Request::from_parts(parts, boxed_body); - let client = self.client.clone(); - - Box::pin(async move { - // Make the request - let resp = client - .request(hyper_req) - .await - .map_err(TransportError::new)?; - - let (parts, body) = resp.into_parts(); - - // Convert hyper's Incoming body to ByteStream - let byte_stream: ByteStream = Box::pin(body_to_stream(body)); - - Ok(Response::from_parts(parts, byte_stream)) - }) - } -} - -/// Convert hyper's Incoming body to a Stream of Bytes -fn body_to_stream( - body: Incoming, -) -> impl futures::Stream> + Send { - futures::stream::unfold(body, |mut body| async move { - match body.frame().await { - Some(Ok(frame)) => { - if let Ok(data) = frame.into_data() { - // Successfully got data frame - Some((Ok(data), body)) - } else { - // Skip non-data frames (trailers, etc.) - Some(( - Err(TransportError::new(std::io::Error::other("non-data frame"))), - body, - )) - } - } - Some(Err(e)) => { - // Error reading frame - Some((Err(TransportError::new(e)), body)) - } - None => { - // End of stream - None - } - } - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_hyper_transport_new() { - let transport = HyperTransport::new(); - // If we can create it without panic, the test passes - // This verifies the default HTTP connector is set up correctly - drop(transport); - } - - #[test] - fn test_hyper_transport_default() { - let transport = HyperTransport::default(); - // Verify Default trait implementation - drop(transport); - } - - #[cfg(feature = "hyper-rustls")] - #[test] - fn test_hyper_transport_new_https() { - let transport = HyperTransport::new_https(); - // If we can create it without panic, the test passes - // This verifies the HTTPS connector with rustls is set up correctly - drop(transport); - } - - #[test] - fn test_new_with_connector() { - use hyper_util::client::legacy::connect::HttpConnector; - - let connector = HttpConnector::new(); - let transport = HyperTransport::new_with_connector(connector); - // Verify we can build with a custom connector - drop(transport); - } -} From 921a92d7b76dc65db37eb92cc2a098532e5d3681 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Mon, 23 Feb 2026 13:48:20 -0500 Subject: [PATCH 11/15] feat: Add native-tls feature (#151) --- .github/actions/build-docs/action.yml | 9 --- .github/actions/ci/action.yml | 16 +++-- .github/actions/contract-tests/action.yml | 12 ++-- .github/workflows/ci.yml | 66 ++++++++----------- .github/workflows/manual-publish.yml | 1 - Makefile | 6 +- contract-tests/Cargo.toml | 29 ++++++-- contract-tests/src/main.rs | 45 ++++++++----- launchdarkly-server-sdk/Cargo.toml | 29 ++++++-- launchdarkly-server-sdk/src/config.rs | 58 +++++++++++++--- .../src/data_source_builders.rs | 28 ++++++-- .../src/events/processor_builders.rs | 19 +++++- 12 files changed, 211 insertions(+), 107 deletions(-) delete mode 100644 .github/actions/build-docs/action.yml diff --git a/.github/actions/build-docs/action.yml b/.github/actions/build-docs/action.yml deleted file mode 100644 index 480322e..0000000 --- a/.github/actions/build-docs/action.yml +++ /dev/null @@ -1,9 +0,0 @@ -name: Build Documentation -description: 'Build Documentation.' - -runs: - using: composite - steps: - - name: Build Documentation - shell: bash - run: cargo doc --no-deps --all-features -p launchdarkly-server-sdk diff --git a/.github/actions/ci/action.yml b/.github/actions/ci/action.yml index 3040232..b8db4ef 100644 --- a/.github/actions/ci/action.yml +++ b/.github/actions/ci/action.yml @@ -1,11 +1,15 @@ name: CI Workflow -description: 'Shared CI workflow.' +description: "Shared CI workflow." inputs: - feature-flags: - description: 'Cargo feature flags to pass to test and clippy commands' + cargo-flags: + description: "Flags to pass to cargo commands." required: false - default: '' + default: "" + cargo-test-flags: + description: "Flags to pass to cargo test commands." + required: false + default: "" runs: using: composite @@ -16,8 +20,8 @@ runs: - name: Run tests shell: bash - run: cargo test ${{ inputs.feature-flags }} -p launchdarkly-server-sdk + run: cargo test ${{ inputs.cargo-flags }} ${{ inputs.cargo-test-flags }} -p launchdarkly-server-sdk - name: Run clippy checks shell: bash - run: cargo clippy ${{ inputs.feature-flags }} -p launchdarkly-server-sdk -- -D warnings + run: cargo clippy ${{ inputs.cargo-flags }} -p launchdarkly-server-sdk -- -D warnings diff --git a/.github/actions/contract-tests/action.yml b/.github/actions/contract-tests/action.yml index ccb165e..4222eb4 100644 --- a/.github/actions/contract-tests/action.yml +++ b/.github/actions/contract-tests/action.yml @@ -1,11 +1,11 @@ name: Contract test runner -description: 'Reusable contract runner action' +description: "Reusable contract runner action" inputs: - tls_feature: - description: 'Which TLS feature do you want to enable?' + cargo-flags: + description: "Flags to pass to cargo commands." required: true token: - description: 'GH Token used for retrieving SDK test harness.' + description: "GH Token used for retrieving SDK test harness." required: true runs: @@ -13,11 +13,11 @@ runs: steps: - name: Build contract tests shell: bash - run: TLS_FEATURE="${{ inputs.tls_feature }}" make build-contract-tests + run: CARGO_FLAGS="${{ inputs.cargo-flags }}" make build-contract-tests - name: Start contract test service shell: bash - run: TLS_FEATURE="${{ inputs.tls_feature }}" make start-contract-test-service-bg + run: CARGO_FLAGS="${{ inputs.cargo-flags }}" make start-contract-test-service-bg - uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1.0.2 with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 466d017..e6be473 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,41 +17,31 @@ jobs: matrix: features: - name: "default" - flags: "" + - name: "no-features" - flags: "--no-default-features" - - name: "hyper" - flags: "--no-default-features --features hyper" - - name: "hyper-rustls" - flags: "--no-default-features --features hyper-rustls" + cargo-flags: "--no-default-features" + cargo-test-flags: "--lib" + skip_contract_tests: "true" - name: CI (${{ matrix.features.name }}) + - name: "hyper" + cargo-flags: "--no-default-features --features hyper" + cargo-test-flags: "--lib" - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 # If you only need the current version keep this. + - name: "hyper-rustls-native-roots" + cargo-flags: "--no-default-features --features hyper-rustls-native-roots" - - name: Get Rust version - id: rust-version - run: cat ./.github/variables/rust-versions.env >> $GITHUB_OUTPUT + - name: "hyper-rustls-webpki-roots" + cargo-flags: "--no-default-features --features hyper-rustls-webpki-roots" - - name: Setup rust tooling - run: | - rustup override set ${{ steps.rust-version.outputs.target }} - rustup component add rustfmt clippy + - name: "native-tls" + cargo-flags: "--no-default-features --features native-tls" - - uses: ./.github/actions/ci - with: - feature-flags: ${{ matrix.features.flags }} - - contract-tests: - runs-on: ubuntu-latest + name: CI (${{ matrix.features.name }}) steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 + fetch-depth: 0 # If you only need the current version keep this. - name: Get Rust version id: rust-version @@ -62,16 +52,15 @@ jobs: rustup override set ${{ steps.rust-version.outputs.target }} rustup component add rustfmt clippy - - name: "Run contract tests with hyper_rustls" - uses: ./.github/actions/contract-tests + - uses: ./.github/actions/ci with: - tls_feature: "hyper-rustls" - token: ${{ secrets.GITHUB_TOKEN }} + cargo-flags: ${{ matrix.features.cargo-flags }} + cargo-test-flags: ${{ matrix.features.cargo-test-flags }} - - name: "Run contract tests with hyper_tls" - uses: ./.github/actions/contract-tests + - uses: ./.github/actions/contract-tests + if: ${{ matrix.features.skip_contract_tests != 'true' }} with: - tls_feature: "tls" + cargo-flags: ${{ matrix.features.cargo-flags }} token: ${{ secrets.GITHUB_TOKEN }} build-docs: @@ -83,15 +72,14 @@ jobs: with: fetch-depth: 0 - - name: Get Rust version - id: rust-version - run: cat ./.github/variables/rust-versions.env >> $GITHUB_OUTPUT - - name: Setup rust tooling - run: | - rustup override set ${{ steps.rust-version.outputs.target }} + run: rustup override set nightly + + - name: Install cargo-docs-rs + run: cargo install cargo-docs-rs - - uses: ./.github/actions/build-docs + - name: Build documentation + run: cargo docs-rs -p launchdarkly-server-sdk musl-build: runs-on: ubuntu-latest diff --git a/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml index b9d1e0b..76c5f30 100644 --- a/.github/workflows/manual-publish.yml +++ b/.github/workflows/manual-publish.yml @@ -27,7 +27,6 @@ jobs: rustup component add rustfmt clippy - uses: ./.github/actions/ci - - uses: ./.github/actions/build-docs - uses: launchdarkly/gh-actions/actions/release-secrets@release-secrets-v1.2.0 name: "Get crates.io token" diff --git a/Makefile b/Makefile index 307093a..4f069d0 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,15 @@ TEMP_TEST_OUTPUT=/tmp/contract-test-service.log -TLS_FEATURE ?= hyper-rustls +CARGO_FLAGS ?= hyper-rustls-native-roots build-contract-tests: - cargo build -p contract-tests --release --no-default-features --features "$(TLS_FEATURE)" + cargo build -p contract-tests --release $(CARGO_FLAGS) start-contract-test-service: build-contract-tests @./target/release/contract-tests start-contract-test-service-bg: @echo "Test service output will be captured in $(TEMP_TEST_OUTPUT)" - @make start-contract-test-service >$(TEMP_TEST_OUTPUT) 2>&1 & + @$(MAKE) start-contract-test-service >$(TEMP_TEST_OUTPUT) 2>&1 & run-contract-tests: @curl -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/main/downloader/run.sh \ diff --git a/contract-tests/Cargo.toml b/contract-tests/Cargo.toml index 1f48685..e7dec85 100644 --- a/contract-tests/Cargo.toml +++ b/contract-tests/Cargo.toml @@ -17,12 +17,33 @@ serde = { version = "1.0.132", features = ["derive"] } serde_json = "1.0.73" futures = "0.3.12" hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2", "tokio"] } -hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "webpki-roots"], optional = true } +hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "ring"], optional = true } hyper-tls = { version = "0.6.0", optional = true } reqwest = { version = "0.12.4", features = ["default", "blocking", "json"] } async-mutex = "1.4.0" [features] -default = ["hyper-rustls"] -hyper-rustls = ["dep:hyper-rustls", "launchdarkly-server-sdk/hyper-rustls"] -tls = ["hyper-tls", "launchdarkly-server-sdk/hyper"] +default = ["hyper"] + +hyper = [ + "launchdarkly-sdk-transport/hyper", + "eventsource-client/hyper" +] +hyper-rustls-native-roots = [ + "hyper", + "dep:hyper-rustls", + "launchdarkly-sdk-transport/hyper-rustls-native-roots", + "eventsource-client/hyper-rustls-native-roots" +] +hyper-rustls-webpki-roots = [ + "hyper", + "dep:hyper-rustls", + "launchdarkly-sdk-transport/hyper-rustls-webpki-roots", + "eventsource-client/hyper-rustls-webpki-roots" +] +native-tls = [ + "hyper", + "dep:hyper-tls", + "launchdarkly-sdk-transport/native-tls", + "eventsource-client/native-tls" +] diff --git a/contract-tests/src/main.rs b/contract-tests/src/main.rs index 85bc036..2d00e03 100644 --- a/contract-tests/src/main.rs +++ b/contract-tests/src/main.rs @@ -207,31 +207,31 @@ struct AppState { https_connector: HttpsConnector, } -#[cfg(feature = "hyper-rustls")] +#[cfg(any( + feature = "hyper-rustls-native-roots", + feature = "hyper-rustls-webpki-roots" +))] type HttpsConnector = hyper_rustls::HttpsConnector; -#[cfg(feature = "tls")] +#[cfg(feature = "native-tls")] type HttpsConnector = hyper_tls::HttpsConnector; +#[cfg(not(any( + feature = "hyper-rustls-native-roots", + feature = "hyper-rustls-webpki-roots", + feature = "native-tls" +)))] +type HttpsConnector = hyper_util::client::legacy::connect::HttpConnector; + #[actix_web::main] + async fn main() -> std::io::Result<()> { env_logger::init(); - #[cfg(not(any(feature = "tls", feature = "hyper-rustls")))] - { - compile_error!("one of the { \"tls\", \"hyper-rustls\" } features must be enabled"); - } - #[cfg(all(feature = "tls", feature = "hyper-rustls"))] - { - compile_error!( - "only one of the { \"tls\", \"hyper-rustls\" } features can be enabled at a time" - ); - } - let (tx, rx) = mpsc::channel::<()>(); - #[cfg(feature = "hyper-rustls")] + #[cfg(feature = "hyper-rustls-native-roots")] let https_connector = hyper_rustls::HttpsConnectorBuilder::new() .with_native_roots() .expect("Failed to load native root certificates") @@ -240,9 +240,24 @@ async fn main() -> std::io::Result<()> { .enable_http2() .build(); - #[cfg(feature = "tls")] + #[cfg(feature = "hyper-rustls-webpki-roots")] + let https_connector = hyper_rustls::HttpsConnectorBuilder::new() + .with_webpki_roots() + .https_or_http() + .enable_http1() + .enable_http2() + .build(); + + #[cfg(feature = "native-tls")] let https_connector = hyper_tls::HttpsConnector::new(); + #[cfg(not(any( + feature = "hyper-rustls-native-roots", + feature = "hyper-rustls-webpki-roots", + feature = "native-tls" + )))] + let https_connector = hyper_util::client::legacy::connect::HttpConnector::new(); + let state = web::Data::new(AppState { counter: Mutex::new(0), client_entities: Mutex::new(HashMap::new()), diff --git a/launchdarkly-server-sdk/Cargo.toml b/launchdarkly-server-sdk/Cargo.toml index 7f5b16d..89e8996 100644 --- a/launchdarkly-server-sdk/Cargo.toml +++ b/launchdarkly-server-sdk/Cargo.toml @@ -54,15 +54,34 @@ reqwest = { version = "0.12.4", features = ["json"] } testing_logger = "0.1.1" [features] -default = ["hyper-rustls"] -hyper = ["launchdarkly-sdk-transport/hyper", "eventsource-client/hyper"] -hyper-rustls = ["hyper", "launchdarkly-sdk-transport/hyper-rustls", "eventsource-client/hyper-rustls"] +default = ["hyper-rustls-native-roots"] + +hyper = [ + "launchdarkly-sdk-transport/hyper", + "eventsource-client/hyper" +] +hyper-rustls-native-roots = [ + "hyper", + "launchdarkly-sdk-transport/hyper-rustls-native-roots", + "eventsource-client/hyper-rustls-native-roots" +] +hyper-rustls-webpki-roots = [ + "hyper", + "launchdarkly-sdk-transport/hyper-rustls-webpki-roots", + "eventsource-client/hyper-rustls-webpki-roots" +] +native-tls = [ + "hyper", + "launchdarkly-sdk-transport/native-tls", + "eventsource-client/native-tls" +] + event-compression = ["flate2"] [[example]] name = "print_flags" -required-features = ["hyper-rustls"] +required-features = ["hyper-rustls-native-roots"] [[example]] name = "progress" -required-features = ["hyper-rustls"] +required-features = ["hyper-rustls-native-roots"] diff --git a/launchdarkly-server-sdk/src/config.rs b/launchdarkly-server-sdk/src/config.rs index 07bd8a7..5f5b6f3 100644 --- a/launchdarkly-server-sdk/src/config.rs +++ b/launchdarkly-server-sdk/src/config.rs @@ -1,11 +1,23 @@ use thiserror::Error; use crate::data_source_builders::{DataSourceFactory, NullDataSourceBuilder}; -use crate::events::processor_builders::{ - EventProcessorBuilder, EventProcessorFactory, NullEventProcessorBuilder, -}; + +#[cfg(any( + feature = "hyper-rustls-native-roots", + feature = "hyper-rustls-webpki-roots", + feature = "native-tls" +))] +use crate::events::processor_builders::EventProcessorBuilder; +use crate::events::processor_builders::{EventProcessorFactory, NullEventProcessorBuilder}; + use crate::stores::store_builders::{DataStoreFactory, InMemoryDataStoreBuilder}; -use crate::{ServiceEndpointsBuilder, StreamingDataSourceBuilder}; +use crate::ServiceEndpointsBuilder; +#[cfg(any( + feature = "hyper-rustls-native-roots", + feature = "hyper-rustls-webpki-roots", + feature = "native-tls" +))] +use crate::StreamingDataSourceBuilder; use std::borrow::Borrow; @@ -300,7 +312,11 @@ impl ConfigBuilder { Ok(Box::new(NullDataSourceBuilder::new())) } Some(builder) => Ok(builder), - #[cfg(feature = "hyper-rustls")] + #[cfg(any( + feature = "hyper-rustls-native-roots", + feature = "hyper-rustls-webpki-roots", + feature = "native-tls" + ))] None => { let transport = launchdarkly_sdk_transport::HyperTransport::new_https() .map_err(|e| { @@ -313,9 +329,13 @@ impl ConfigBuilder { builder.transport(transport); Ok(Box::new(builder)) } - #[cfg(not(feature = "hyper-rustls"))] + #[cfg(not(any( + feature = "hyper-rustls-native-roots", + feature = "hyper-rustls-webpki-roots", + feature = "native-tls" + )))] None => Err(BuildError::InvalidConfig( - "data source builder required when hyper-rustls feature is disabled".into(), + "data source builder required when hyper-rustls-native-roots, hyper-rustls-webpki-roots, or native-tls features are disabled".into(), )), }; let data_source_builder = data_source_builder_result?; @@ -328,7 +348,11 @@ impl ConfigBuilder { Ok(Box::new(NullEventProcessorBuilder::new())) } Some(builder) => Ok(builder), - #[cfg(feature = "hyper-rustls")] + #[cfg(any( + feature = "hyper-rustls-native-roots", + feature = "hyper-rustls-webpki-roots", + feature = "native-tls" + ))] None => { let transport = launchdarkly_sdk_transport::HyperTransport::new_https() .map_err(|e| { @@ -341,9 +365,13 @@ impl ConfigBuilder { builder.transport(transport); Ok(Box::new(builder)) } - #[cfg(not(feature = "hyper-rustls"))] + #[cfg(not(any( + feature = "hyper-rustls-native-roots", + feature = "hyper-rustls-webpki-roots", + feature = "native-tls" + )))] None => Err(BuildError::InvalidConfig( - "event processor factory required when hyper-rustls feature is disabled".into(), + "event processor factory required when hyper-rustls-native-roots, hyper-rustls-webpki-roots, or native-tls features are disabled".into(), )), }; let event_processor_builder = event_processor_builder_result?; @@ -391,6 +419,11 @@ mod tests { } #[test] + #[cfg(any( + feature = "hyper-rustls-native-roots", + feature = "hyper-rustls-webpki-roots", + feature = "native-tls" + ))] fn unconfigured_config_builder_handles_application_tags_correctly() { let builder = ConfigBuilder::new("sdk-key"); let config = builder.build().expect("config should build"); @@ -402,6 +435,11 @@ mod tests { #[test_case("Invalid id", "version", Some("application-version/version".to_string()))] #[test_case("id", "Invalid version", Some("application-id/id".to_string()))] #[test_case("Invalid id", "Invalid version", None)] + #[cfg(any( + feature = "hyper-rustls-native-roots", + feature = "hyper-rustls-webpki-roots", + feature = "native-tls" + ))] fn config_builder_handles_application_tags_appropriately( id: impl Into, version: impl Into, diff --git a/launchdarkly-server-sdk/src/data_source_builders.rs b/launchdarkly-server-sdk/src/data_source_builders.rs index b060111..d0a56e3 100644 --- a/launchdarkly-server-sdk/src/data_source_builders.rs +++ b/launchdarkly-server-sdk/src/data_source_builders.rs @@ -92,7 +92,11 @@ impl DataSourceFactory tags: Option, ) -> Result, BuildError> { let data_source_result = match &self.transport { - #[cfg(feature = "hyper-rustls")] + #[cfg(any( + feature = "hyper-rustls-native-roots", + feature = "hyper-rustls-webpki-roots", + feature = "native-tls" + ))] None => { let transport = launchdarkly_sdk_transport::HyperTransport::new_https().map_err(|e| { @@ -108,9 +112,13 @@ impl DataSourceFactory transport, )) } - #[cfg(not(feature = "hyper-rustls"))] + #[cfg(not(any( + feature = "hyper-rustls-native-roots", + feature = "hyper-rustls-webpki-roots", + feature = "native-tls" + )))] None => Err(BuildError::InvalidConfig( - "https connector required when rustls is disabled".into(), + "https connector required when hyper-rustls-native-roots, hyper-rustls-webpki-roots, or native-tls features are disabled".into(), )), Some(transport) => Ok(StreamingDataSource::new( endpoints.streaming_base_url(), @@ -254,7 +262,11 @@ impl DataSourceFactory for PollingDataSourceBuilder { ) -> Result, BuildError> { let feature_requester_builder: Result, BuildError> = match &self.transport { - #[cfg(feature = "hyper-rustls")] + #[cfg(any( + feature = "hyper-rustls-native-roots", + feature = "hyper-rustls-webpki-roots", + feature = "native-tls" + ))] None => { let transport = launchdarkly_sdk_transport::HyperTransport::new_https() .map_err(|e| { @@ -269,9 +281,13 @@ impl DataSourceFactory for PollingDataSourceBuilder { transport, ))) } - #[cfg(not(feature = "hyper-rustls"))] + #[cfg(not(any( + feature = "hyper-rustls-native-roots", + feature = "hyper-rustls-webpki-roots", + feature = "native-tls" + )))] None => Err(BuildError::InvalidConfig( - "transport is required when hyper-rustls feature is disabled".into(), + "transport is required when hyper-rustls-native-roots, hyper-rustls-webpki-roots, or native-tls features are disabled".into(), )), Some(transport) => Ok(Box::new(HttpFeatureRequesterBuilder::new( endpoints.polling_base_url(), diff --git a/launchdarkly-server-sdk/src/events/processor_builders.rs b/launchdarkly-server-sdk/src/events/processor_builders.rs index 1d4ebf9..714be7e 100644 --- a/launchdarkly-server-sdk/src/events/processor_builders.rs +++ b/launchdarkly-server-sdk/src/events/processor_builders.rs @@ -111,7 +111,11 @@ impl EventProcessorFactory for EventProcessorBuilder { self.compress_events, ))) } else { - #[cfg(feature = "hyper-rustls")] + #[cfg(any( + feature = "hyper-rustls-native-roots", + feature = "hyper-rustls-webpki-roots", + feature = "native-tls" + ))] { let transport = launchdarkly_sdk_transport::HyperTransport::new_https().map_err(|e| { BuildError::InvalidConfig(format!( @@ -127,9 +131,13 @@ impl EventProcessorFactory for EventProcessorBuilder { self.compress_events, ))) } - #[cfg(not(feature = "hyper-rustls"))] + #[cfg(not(any( + feature = "hyper-rustls-native-roots", + feature = "hyper-rustls-webpki-roots", + feature = "native-tls" + )))] Err(BuildError::InvalidConfig( - "transport is required when hyper-rustls feature is disabled".into(), + "transport is required when hyper-rustls-native-roots, hyper-rustls-webpki-roots, or native-tls features are disabled".into(), )) }; let event_sender = event_sender_result?; @@ -386,6 +394,11 @@ mod tests { #[test_case(Some("application-id/abc:application-sha/xyz".into()), "application-id/abc:application-sha/xyz")] #[test_case(None, Matcher::Missing)] + #[cfg(any( + feature = "hyper-rustls-native-roots", + feature = "hyper-rustls-webpki-roots", + feature = "native-tls" + ))] fn processor_sends_correct_headers(tag: Option, matcher: impl Into) { let mut server = mockito::Server::new(); let mock = server From 15ec0ab741b96ae57b7400329844cc57166a9a93 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Mon, 23 Feb 2026 15:36:59 -0500 Subject: [PATCH 12/15] feat: Choose crypto library with crypto-(aws-lc-rs|openssl) features (#152) fixes #126 --- .github/workflows/ci.yml | 5 ++- contract-tests/Cargo.toml | 5 ++- contract-tests/src/client_entity.rs | 13 ++++-- contract-tests/src/command_params.rs | 4 ++ contract-tests/src/main.rs | 1 + launchdarkly-server-sdk/Cargo.toml | 8 +++- launchdarkly-server-sdk/src/client.rs | 65 ++++++++++++++++++++++++--- 7 files changed, 86 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6be473..d6df4fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: fail-fast: false matrix: features: - - name: "default" + - name: "default (aws-lc-rs for crypto)" - name: "no-features" cargo-flags: "--no-default-features" @@ -36,6 +36,9 @@ jobs: - name: "native-tls" cargo-flags: "--no-default-features --features native-tls" + - name: "openssl crypto" + cargo-flags: "--no-default-features --features hyper-rustls-native-roots,crypto-openssl" + name: CI (${{ matrix.features.name }}) steps: diff --git a/contract-tests/Cargo.toml b/contract-tests/Cargo.toml index e7dec85..ba07039 100644 --- a/contract-tests/Cargo.toml +++ b/contract-tests/Cargo.toml @@ -23,7 +23,10 @@ reqwest = { version = "0.12.4", features = ["default", "blocking", "json"] } async-mutex = "1.4.0" [features] -default = ["hyper"] +default = ["hyper", "crypto-aws-lc-rs"] + +crypto-aws-lc-rs = ["launchdarkly-server-sdk/crypto-aws-lc-rs"] +crypto-openssl = ["launchdarkly-server-sdk/crypto-openssl"] hyper = [ "launchdarkly-sdk-transport/hyper", diff --git a/contract-tests/src/client_entity.rs b/contract-tests/src/client_entity.rs index 14dffed..ee9036a 100644 --- a/contract-tests/src/client_entity.rs +++ b/contract-tests/src/client_entity.rs @@ -15,9 +15,11 @@ use launchdarkly_server_sdk::{ ServiceEndpointsBuilder, StreamingDataSourceBuilder, }; +#[cfg(any(feature = "crypto-aws-lc-rs", feature = "crypto-openssl"))] +use crate::command_params::SecureModeHashResponse; use crate::command_params::{ ContextBuildParams, ContextConvertParams, ContextParam, ContextResponse, - MigrationOperationResponse, MigrationVariationResponse, SecureModeHashResponse, + MigrationOperationResponse, MigrationVariationResponse, }; use crate::HttpsConnector; use crate::{ @@ -221,14 +223,17 @@ impl ClientEntity { ContextResponse::from(Self::context_convert(params)), ))) } + #[cfg(any(feature = "crypto-aws-lc-rs", feature = "crypto-openssl"))] "secureModeHash" => { let params = command .secure_mode_hash .ok_or("secureModeHash params should be set")?; + let hash = self + .client + .secure_mode_hash(¶ms.context) + .map_err(|e| e.to_string())?; Ok(Some(CommandResponse::SecureModeHash( - SecureModeHashResponse { - result: self.client.secure_mode_hash(¶ms.context), - }, + SecureModeHashResponse { result: hash }, ))) } "migrationVariation" => { diff --git a/contract-tests/src/command_params.rs b/contract-tests/src/command_params.rs index 5bbf495..2d19593 100644 --- a/contract-tests/src/command_params.rs +++ b/contract-tests/src/command_params.rs @@ -10,6 +10,7 @@ pub enum CommandResponse { EvaluateFlag(EvaluateFlagResponse), EvaluateAll(EvaluateAllFlagsResponse), ContextBuildOrConvert(ContextResponse), + #[cfg(any(feature = "crypto-aws-lc-rs", feature = "crypto-openssl"))] SecureModeHash(SecureModeHashResponse), MigrationVariation(MigrationVariationResponse), MigrationOperation(MigrationOperationResponse), @@ -25,6 +26,7 @@ pub struct CommandParams { pub identify_event: Option, pub context_build: Option, pub context_convert: Option, + #[cfg(any(feature = "crypto-aws-lc-rs", feature = "crypto-openssl"))] pub secure_mode_hash: Option, pub migration_variation: Option, pub migration_operation: Option, @@ -126,12 +128,14 @@ pub struct ContextConvertParams { pub input: String, } +#[cfg(any(feature = "crypto-aws-lc-rs", feature = "crypto-openssl"))] #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct SecureModeHashParams { pub context: Context, } +#[cfg(any(feature = "crypto-aws-lc-rs", feature = "crypto-openssl"))] #[derive(Serialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct SecureModeHashResponse { diff --git a/contract-tests/src/main.rs b/contract-tests/src/main.rs index 2d00e03..480b2ad 100644 --- a/contract-tests/src/main.rs +++ b/contract-tests/src/main.rs @@ -103,6 +103,7 @@ async fn status() -> impl Responder { "tags".to_string(), "service-endpoints".to_string(), "context-type".to_string(), + #[cfg(any(feature = "crypto-aws-lc-rs", feature = "crypto-openssl"))] "secure-mode-hash".to_string(), "inline-context-all".to_string(), "anonymous-redaction".to_string(), diff --git a/launchdarkly-server-sdk/Cargo.toml b/launchdarkly-server-sdk/Cargo.toml index 89e8996..e21934c 100644 --- a/launchdarkly-server-sdk/Cargo.toml +++ b/launchdarkly-server-sdk/Cargo.toml @@ -39,7 +39,8 @@ bytes = "1.11" bitflags = "2.4" rand = "0.9" flate2 = { version = "1.0.35", optional = true } -aws-lc-rs = "1.14.1" +aws-lc-rs = { version = "1.14.1", optional = true } +openssl = { version = "0.10.75", optional = true } [dev-dependencies] maplit = "1.0.1" @@ -54,7 +55,10 @@ reqwest = { version = "0.12.4", features = ["json"] } testing_logger = "0.1.1" [features] -default = ["hyper-rustls-native-roots"] +default = ["hyper-rustls-native-roots", "crypto-aws-lc-rs"] + +crypto-aws-lc-rs = ["dep:aws-lc-rs"] +crypto-openssl = ["dep:openssl"] hyper = [ "launchdarkly-sdk-transport/hyper", diff --git a/launchdarkly-server-sdk/src/client.rs b/launchdarkly-server-sdk/src/client.rs index 39af0d8..c9b5a3d 100644 --- a/launchdarkly-server-sdk/src/client.rs +++ b/launchdarkly-server-sdk/src/client.rs @@ -156,6 +156,10 @@ pub struct Client { started: AtomicBool, offline: bool, daemon_mode: bool, + #[cfg_attr( + not(any(feature = "crypto-openssl", feature = "crypto-aws-lc-rs")), + allow(dead_code) + )] sdk_key: String, shutdown_broadcast: broadcast::Sender<()>, runtime: RwLock>, @@ -585,15 +589,39 @@ impl Client { .try_map(|val| val.as_json(), default, eval::Error::WrongType) } + #[cfg(any(feature = "crypto-aws-lc-rs", feature = "crypto-openssl"))] /// Generates the secure mode hash value for a context. /// /// For more information, see the Reference Guide: /// . - pub fn secure_mode_hash(&self, context: &Context) -> String { - let key = aws_lc_rs::hmac::Key::new(aws_lc_rs::hmac::HMAC_SHA256, self.sdk_key.as_bytes()); - let tag = aws_lc_rs::hmac::sign(&key, context.canonical_key().as_bytes()); - - data_encoding::HEXLOWER.encode(tag.as_ref()) + pub fn secure_mode_hash(&self, context: &Context) -> Result { + #[cfg(feature = "crypto-aws-lc-rs")] + { + let key = + aws_lc_rs::hmac::Key::new(aws_lc_rs::hmac::HMAC_SHA256, self.sdk_key.as_bytes()); + let tag = aws_lc_rs::hmac::sign(&key, context.canonical_key().as_bytes()); + + Ok(data_encoding::HEXLOWER.encode(tag.as_ref())) + } + #[cfg(feature = "crypto-openssl")] + { + use openssl::hash::MessageDigest; + use openssl::pkey::PKey; + use openssl::sign::Signer; + + let key = PKey::hmac(self.sdk_key.as_bytes()) + .map_err(|e| format!("Failed to create HMAC key: {e}"))?; + let mut signer = Signer::new(MessageDigest::sha256(), &key) + .map_err(|e| format!("Failed to create signer: {e}"))?; + signer + .update(context.canonical_key().as_bytes()) + .map_err(|e| format!("Failed to update signer: {e}"))?; + let hmac = signer + .sign_to_vec() + .map_err(|e| format!("Failed to sign: {e}"))?; + + Ok(data_encoding::HEXLOWER.encode(&hmac)) + } } /// Returns an object that encapsulates the state of all feature flags for a given context. This @@ -1830,6 +1858,7 @@ mod tests { } #[test] + #[cfg(any(feature = "crypto-aws-lc-rs", feature = "crypto-openssl"))] fn secure_mode_hash() { let config = ConfigBuilder::new("secret") .offline(true) @@ -1841,12 +1870,15 @@ mod tests { .expect("Failed to create context"); assert_eq!( - client.secure_mode_hash(&context), + client + .secure_mode_hash(&context) + .expect("Hash should be computed"), "aa747c502a898200f9e4fa21bac68136f886a0e27aec70ba06daf2e2a5cb5597" ); } #[test] + #[cfg(any(feature = "crypto-aws-lc-rs", feature = "crypto-openssl"))] fn secure_mode_hash_with_multi_kind() { let config = ConfigBuilder::new("secret") .offline(true) @@ -1869,7 +1901,9 @@ mod tests { .expect("failed to build multi-context"); assert_eq!( - client.secure_mode_hash(&context), + client + .secure_mode_hash(&context) + .expect("Hash should be computed"), "5687e6383b920582ed50c2a96c98a115f1b6aad85a60579d761d9b8797415163" ); } @@ -2740,4 +2774,21 @@ mod tests { fn make_mocked_client() -> (Client, Receiver) { make_mocked_client_with_delay(0, false, false) } + + #[test] + fn client_builds_successfully() { + let config = ConfigBuilder::new("sdk-key") + .offline(true) + .build() + .expect("config should build"); + + let client = Client::build(config).expect("client should build successfully"); + + assert!( + !client.started.load(Ordering::SeqCst), + "client should not be started yet" + ); + assert!(client.offline, "client should be in offline mode"); + assert_eq!(client.sdk_key, "sdk-key", "sdk_key should match"); + } } From 3813e05d86acb76b9e993faca34d3f16e8308c10 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Mon, 23 Feb 2026 15:48:21 -0500 Subject: [PATCH 13/15] chore: Support http-proxy contract-test capability (#153) --- contract-tests/src/client_entity.rs | 20 ++++++++++++++------ contract-tests/src/main.rs | 9 +++++++++ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/contract-tests/src/client_entity.rs b/contract-tests/src/client_entity.rs index ee9036a..ca457c2 100644 --- a/contract-tests/src/client_entity.rs +++ b/contract-tests/src/client_entity.rs @@ -39,11 +39,19 @@ impl ClientEntity { create_instance_params: CreateInstanceParams, connector: HttpsConnector, ) -> Result { + let proxy = create_instance_params + .configuration + .proxy + .unwrap_or_default() + .http_proxy + .unwrap_or_default(); + let mut transport_builder = launchdarkly_sdk_transport::HyperTransport::builder(); + if !proxy.is_empty() { + transport_builder = transport_builder.proxy_url(proxy.clone()); + } + // Create fresh transports for this client to avoid shared connection pool issues - let transport = launchdarkly_sdk_transport::HyperTransport::builder() - .build_with_connector(connector.clone()) - .map_err(|e| BuildError::InvalidConfig(e.to_string()))?; - let streaming_https_transport = launchdarkly_sdk_transport::HyperTransport::builder() + let transport = transport_builder .build_with_connector(connector.clone()) .map_err(|e| BuildError::InvalidConfig(e.to_string()))?; let mut config_builder = @@ -88,7 +96,7 @@ impl ClientEntity { if let Some(delay) = streaming.initial_retry_delay_ms { streaming_builder.initial_reconnect_delay(Duration::from_millis(delay)); } - streaming_builder.transport(streaming_https_transport.clone()); + streaming_builder.transport(transport.clone()); config_builder = config_builder.data_source(&streaming_builder); } else if let Some(polling) = create_instance_params.configuration.polling { @@ -108,7 +116,7 @@ impl ClientEntity { // customization we provide is the transport to support testing multiple // transport implementations. let mut streaming_builder = StreamingDataSourceBuilder::new(); - streaming_builder.transport(streaming_https_transport); + streaming_builder.transport(transport.clone()); config_builder = config_builder.data_source(&streaming_builder); } diff --git a/contract-tests/src/main.rs b/contract-tests/src/main.rs index 480b2ad..676b361 100644 --- a/contract-tests/src/main.rs +++ b/contract-tests/src/main.rs @@ -18,6 +18,12 @@ struct Status { capabilities: Vec, } +#[derive(Deserialize, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub struct ProxyParameters { + pub http_proxy: Option, +} + #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct StreamingParameters { @@ -72,6 +78,8 @@ pub struct Configuration { #[serde(default = "bool::default")] pub init_can_fail: bool, + pub proxy: Option, + pub streaming: Option, pub polling: Option, @@ -103,6 +111,7 @@ async fn status() -> impl Responder { "tags".to_string(), "service-endpoints".to_string(), "context-type".to_string(), + "http-proxy".to_string(), #[cfg(any(feature = "crypto-aws-lc-rs", feature = "crypto-openssl"))] "secure-mode-hash".to_string(), "inline-context-all".to_string(), From ddeb1e051af84603743802ffaf81b564b6dcf5e3 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Mon, 23 Feb 2026 16:13:24 -0500 Subject: [PATCH 14/15] feat!: Enable event compression by default (#154) --- contract-tests/src/client_entity.rs | 1 + launchdarkly-server-sdk/Cargo.toml | 8 ++++++-- .../src/events/processor_builders.rs | 10 ++++++---- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/contract-tests/src/client_entity.rs b/contract-tests/src/client_entity.rs index ca457c2..fdf5908 100644 --- a/contract-tests/src/client_entity.rs +++ b/contract-tests/src/client_entity.rs @@ -130,6 +130,7 @@ impl ClientEntity { processor_builder.capacity(capacity); } processor_builder.all_attributes_private(events.all_attributes_private); + processor_builder.compress_events(false); if let Some(e) = events.enable_gzip { processor_builder.compress_events(e); } diff --git a/launchdarkly-server-sdk/Cargo.toml b/launchdarkly-server-sdk/Cargo.toml index e21934c..16168d4 100644 --- a/launchdarkly-server-sdk/Cargo.toml +++ b/launchdarkly-server-sdk/Cargo.toml @@ -14,7 +14,7 @@ exclude = [ ] [package.metadata.docs.rs] -features = ["event-compression"] +features = [] [dependencies] chrono = "0.4.19" @@ -55,7 +55,11 @@ reqwest = { version = "0.12.4", features = ["json"] } testing_logger = "0.1.1" [features] -default = ["hyper-rustls-native-roots", "crypto-aws-lc-rs"] +default = [ + "hyper-rustls-native-roots", + "crypto-aws-lc-rs", + "event-compression" +] crypto-aws-lc-rs = ["dep:aws-lc-rs"] crypto-openssl = ["dep:openssl"] diff --git a/launchdarkly-server-sdk/src/events/processor_builders.rs b/launchdarkly-server-sdk/src/events/processor_builders.rs index 714be7e..5a2440a 100644 --- a/launchdarkly-server-sdk/src/events/processor_builders.rs +++ b/launchdarkly-server-sdk/src/events/processor_builders.rs @@ -178,6 +178,9 @@ impl EventProcessorBuilder { private_attributes: HashSet::new(), omit_anonymous_contexts: false, transport: None, + #[cfg(feature = "event-compression")] + compress_events: true, + #[cfg(not(feature = "event-compression"))] compress_events: false, } } @@ -261,11 +264,10 @@ impl EventProcessorBuilder { #[cfg(feature = "event-compression")] /// Should the event payload sent to LaunchDarkly use gzip compression. By - /// default this is false to prevent backward breaking compatibility issues with - /// older versions of the relay proxy. + /// default this is true. // - /// Customers not using the relay proxy are strongly encouraged to enable this - /// feature to reduce egress bandwidth cost. + /// Customers using the relay proxy are encouraged to disable this feature to avoid unnecessary + /// CPU overhead, as the relay proxy will decompress & recompress the payloads. pub fn compress_events(&mut self, enabled: bool) -> &mut Self { self.compress_events = enabled; self From c70c2a09a3d2284fdc80be1bfc87fd89bf3d02cf Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Wed, 25 Feb 2026 13:48:32 -0500 Subject: [PATCH 15/15] chore: Use published version of eventsource client --- contract-tests/Cargo.toml | 2 +- launchdarkly-server-sdk/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contract-tests/Cargo.toml b/contract-tests/Cargo.toml index ba07039..06ebcde 100644 --- a/contract-tests/Cargo.toml +++ b/contract-tests/Cargo.toml @@ -9,7 +9,7 @@ license = "Apache-2.0" actix = "0.13.0" actix-web = "4.2.1" env_logger = "0.10.0" -eventsource-client = { git = "https://github.com/launchdarkly/rust-eventsource-client.git", branch = "feat/hyper-as-feature" } +eventsource-client = { version = "0.17.0" } launchdarkly-sdk-transport = { version = "0.1.0" } log = "0.4.14" launchdarkly-server-sdk = { path = "../launchdarkly-server-sdk/", default-features = false, features = ["event-compression"]} diff --git a/launchdarkly-server-sdk/Cargo.toml b/launchdarkly-server-sdk/Cargo.toml index 16168d4..143b606 100644 --- a/launchdarkly-server-sdk/Cargo.toml +++ b/launchdarkly-server-sdk/Cargo.toml @@ -20,7 +20,7 @@ features = [] chrono = "0.4.19" crossbeam-channel = "0.5.1" data-encoding = "2.3.2" -eventsource-client = { git = "https://github.com/launchdarkly/rust-eventsource-client.git", branch = "feat/hyper-as-feature" } +eventsource-client = { version = "0.17.0" } launchdarkly-sdk-transport = { version = "0.1.0" } futures = "0.3.12" log = "0.4.14"