diff --git a/.sampo/changesets/quiet-otters-degrade.md b/.sampo/changesets/quiet-otters-degrade.md new file mode 100644 index 00000000..2084976d --- /dev/null +++ b/.sampo/changesets/quiet-otters-degrade.md @@ -0,0 +1,5 @@ +--- +cargo/posthog-rs: patch +--- + +Do not panic when the HTTP client cannot be built. A container without CA certificates makes the reqwest builder fail, which stopped the calling program. The client now logs a warning, disables itself, and lets the program continue. The feature flag pollers degrade the same way: they log a warning and stay stopped instead of panicking. diff --git a/src/client/async_client.rs b/src/client/async_client.rs index 102b2185..6af727e6 100644 --- a/src/client/async_client.rs +++ b/src/client/async_client.rs @@ -46,8 +46,8 @@ fn is_retryable_feature_flags_error(err: &reqwest::Error) -> bool { use super::common::{ already_reported, build_dedup_key, extract_flag_details, flag_called_event, - flag_event_dedup_cache, local_record, remote_record_from_detail, report_flags_error, - DetailedFlagsResponse, FlagEventDedupCache, + flag_event_dedup_cache, http_client_or_disable, local_record, remote_record_from_detail, + report_flags_error, DetailedFlagsResponse, FlagEventDedupCache, }; use super::transport::{Completion, Control, TransportHandle}; use super::{CaptureSummary, ClientOptions}; @@ -57,7 +57,7 @@ use reqwest::header::CONTENT_ENCODING; /// A [`Client`] facilitates interactions with the PostHog API over HTTP. pub struct Client { options: ClientOptions, - client: HttpClient, + client: Option, local_evaluator: Option, _flag_poller: Option, flag_event_host: OnceLock>, @@ -126,12 +126,16 @@ impl FeatureFlagEvaluationsHost for AsyncFlagEventHost { /// /// This constructor is available with the default `async-client` feature and /// must be awaited. Passing a blank API key creates a disabled client. +/// If the HTTP client cannot be built, logs a warning and creates a disabled +/// client instead. pub async fn client>(options: C) -> Client { - let options = options.into().sanitize(); - let client = HttpClient::builder() - .timeout(Duration::from_secs(options.request_timeout_seconds)) - .build() - .unwrap(); // Unwrap here is as safe as `HttpClient::new` + let mut options = options.into().sanitize(); + let client = http_client_or_disable( + HttpClient::builder() + .timeout(Duration::from_secs(options.request_timeout_seconds)) + .build(), + &mut options, + ); let (local_evaluator, flag_poller) = if options.enable_local_evaluation && !options.is_disabled() { @@ -178,6 +182,13 @@ pub async fn client>(options: C) -> Client { } impl Client { + /// The HTTP client, or [`Error::Connection`] if initialization failed. + fn http(&self) -> Result<&HttpClient, Error> { + self.client.as_ref().ok_or_else(|| { + Error::Connection("HTTP client is unavailable; PostHog client is disabled".to_string()) + }) + } + /// Capture the provided event, sending it to PostHog. /// /// # Parameters @@ -580,7 +591,7 @@ impl Client { )?; let step = match self - .client + .http()? .post(&prep.url) .headers(headers) .body(body) @@ -646,7 +657,7 @@ impl Client { let mut attempt: u32 = 1; loop { let mut request = self - .client + .http()? .post(&prep.url) .header(CONTENT_TYPE, "application/json") .header(USER_AGENT, get_default_user_agent()) @@ -994,7 +1005,7 @@ impl Client { let distinct_id = payload.get("distinct_id").and_then(|v| v.as_str()); let response = match self - .client + .http()? .post(&flags_endpoint) .header(CONTENT_TYPE, "application/json") .header(USER_AGENT, get_default_user_agent()) @@ -1260,7 +1271,7 @@ impl Client { let mut attempt = 1; loop { let request = self - .client + .http()? .post(flags_endpoint) .header(CONTENT_TYPE, "application/json") .header(USER_AGENT, get_default_user_agent()) @@ -1497,7 +1508,7 @@ mod minimal_gate_tests { let options = ClientOptions::from(("phc_test", "http://localhost:0")); let client = Client { options, - client: HttpClient::builder().build().unwrap(), + client: Some(HttpClient::builder().build().unwrap()), local_evaluator: Some(LocalEvaluator::new(cache)), _flag_poller: None, flag_event_host: OnceLock::new(), @@ -1597,7 +1608,7 @@ mod local_payload_tests { let options = ClientOptions::from(("phc_test", "http://localhost:0")); let client = Client { options, - client: HttpClient::builder().build().unwrap(), + client: Some(HttpClient::builder().build().unwrap()), local_evaluator: Some(LocalEvaluator::new(cache)), _flag_poller: None, flag_event_host: OnceLock::new(), diff --git a/src/client/blocking.rs b/src/client/blocking.rs index 0fb87d6d..e6b129ce 100644 --- a/src/client/blocking.rs +++ b/src/client/blocking.rs @@ -49,8 +49,8 @@ fn is_retryable_feature_flags_error(err: &reqwest::Error) -> bool { use super::common::{ already_reported, build_dedup_key, extract_flag_details, flag_called_event, - flag_event_dedup_cache, local_record, remote_record_from_detail, report_flags_error, - DetailedFlagsResponse, FlagEventDedupCache, + flag_event_dedup_cache, http_client_or_disable, local_record, remote_record_from_detail, + report_flags_error, DetailedFlagsResponse, FlagEventDedupCache, }; use super::transport::{Completion, Control, TransportHandle}; use super::{CaptureSummary, ClientOptions}; @@ -60,7 +60,7 @@ use reqwest::header::CONTENT_ENCODING; /// A [`Client`] facilitates interactions with the PostHog API over HTTP. pub struct Client { options: ClientOptions, - client: HttpClient, + client: Option, local_evaluator: Option, _flag_poller: Option, flag_event_host: OnceLock>, @@ -130,12 +130,16 @@ impl FeatureFlagEvaluationsHost for BlockingFlagEventHost { /// /// Passing a blank API key creates a disabled client. Enable the default /// `async-client` feature to use the async client instead. +/// If the HTTP client cannot be built, logs a warning and creates a disabled +/// client instead. pub fn client>(options: C) -> Client { - let options = options.into().sanitize(); - let client = HttpClient::builder() - .timeout(Duration::from_secs(options.request_timeout_seconds)) - .build() - .unwrap(); // Unwrap here is as safe as `HttpClient::new` + let mut options = options.into().sanitize(); + let client = http_client_or_disable( + HttpClient::builder() + .timeout(Duration::from_secs(options.request_timeout_seconds)) + .build(), + &mut options, + ); let (local_evaluator, flag_poller) = if options.enable_local_evaluation && !options.is_disabled() { @@ -182,6 +186,13 @@ pub fn client>(options: C) -> Client { } impl Client { + /// The HTTP client, or [`Error::Connection`] if initialization failed. + fn http(&self) -> Result<&HttpClient, Error> { + self.client.as_ref().ok_or_else(|| { + Error::Connection("HTTP client is unavailable; PostHog client is disabled".to_string()) + }) + } + /// Capture the provided event, sending it to PostHog. /// /// # Parameters @@ -563,7 +574,7 @@ impl Client { )?; let step = match self - .client + .http()? .post(&prep.url) .headers(headers) .body(body) @@ -627,7 +638,7 @@ impl Client { let mut attempt: u32 = 1; loop { let mut request = self - .client + .http()? .post(&prep.url) .header(CONTENT_TYPE, "application/json") .header(USER_AGENT, get_default_user_agent()) @@ -963,7 +974,7 @@ impl Client { let distinct_id = payload.get("distinct_id").and_then(|v| v.as_str()); let response = match self - .client + .http()? .post(&flags_endpoint) .header(CONTENT_TYPE, "application/json") .header(USER_AGENT, get_default_user_agent()) @@ -1228,7 +1239,7 @@ impl Client { let mut attempt = 1; loop { let request = self - .client + .http()? .post(flags_endpoint) .header(CONTENT_TYPE, "application/json") .header(USER_AGENT, get_default_user_agent()) @@ -1420,7 +1431,7 @@ mod minimal_gate_tests { let options = ClientOptions::from(("phc_test", "http://localhost:0")); let client = Client { options, - client: HttpClient::builder().build().unwrap(), + client: Some(HttpClient::builder().build().unwrap()), local_evaluator: Some(LocalEvaluator::new(cache)), _flag_poller: None, flag_event_host: OnceLock::new(), @@ -1519,7 +1530,7 @@ mod local_payload_tests { let options = ClientOptions::from(("phc_test", "http://localhost:0")); let client = Client { options, - client: HttpClient::builder().build().unwrap(), + client: Some(HttpClient::builder().build().unwrap()), local_evaluator: Some(LocalEvaluator::new(cache)), _flag_poller: None, flag_event_host: OnceLock::new(), diff --git a/src/client/common.rs b/src/client/common.rs index 06059fac..88f6b904 100644 --- a/src/client/common.rs +++ b/src/client/common.rs @@ -3,6 +3,7 @@ use std::sync::{Mutex, OnceLock}; use crate::client::BeforeSendHook; use crate::client::CaptureDefaults; +use crate::client::ClientOptions; use crate::client::FlagsFailure; use crate::client::OnErrorHook; use crate::client::PostHogError; @@ -10,7 +11,7 @@ use crate::feature_flag_evaluations::{EvaluatedFlagRecord, FlagCalledEventParams use crate::feature_flags::{FeatureFlagsResponse, FlagDetail, FlagMetadata, FlagValue}; use crate::Error; use crate::Event; -use tracing::error; +use tracing::{error, warn}; /// Cap on the number of `distinct_id` entries in the `$feature_flag_called` /// dedup cache. On overflow the entire map is reset (matches the JS SDK). @@ -336,6 +337,23 @@ fn normalize_payload(payload: serde_json::Value) -> serde_json::Value { } } +/// Disable the client when HTTP initialization fails, for example when the +/// system has no CA certificates. +pub(super) fn http_client_or_disable( + result: Result, + options: &mut ClientOptions, +) -> Option { + match result { + Ok(client) => Some(client), + Err(err) => { + // Debug includes the underlying builder error. + warn!("Failed to build HTTP client ({err:?}); disabling PostHog client"); + options.disabled = true; + None + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -493,4 +511,21 @@ mod tests { apply_before_send_hooks(&options.before_send, Event::new("test", "user-1")).is_none() ); } + + #[test] + fn http_client_build_failure_disables_client() { + let mut options = crate::ClientOptionsBuilder::default() + .api_key("phc_test".to_string()) + .build() + .unwrap(); + assert!(!options.is_disabled()); + + let client = http_client_or_disable( + Err::<(), _>("failed to load native root certificates"), + &mut options, + ); + + assert!(client.is_none()); + assert!(options.is_disabled()); + } } diff --git a/src/client/transport.rs b/src/client/transport.rs index 1b0e24b9..6876851c 100644 --- a/src/client/transport.rs +++ b/src/client/transport.rs @@ -447,7 +447,14 @@ fn run_worker( // any sane teardown budget. let shutdown_timeout = Duration::from_millis(options.shutdown_timeout_ms).min(MAX_SHUTDOWN_TIMEOUT); - let mut pipeline = Pipeline::new(&options, Arc::clone(&clock), len); + // Without an HTTP client nothing can ever be delivered, so stop the worker + // instead of running a pipeline that cannot send. Producers keep going: any + // queued completion is signalled on the way out, and once the channel is + // disconnected an enqueue releases its reserved slot. + let Some(mut pipeline) = Pipeline::new(&options, Arc::clone(&clock), Arc::clone(&len)) else { + drain_pending_completions(&rx, &len); + return; + }; let mut buffer: Vec = Vec::new(); let mut buffer_since: Option = None; @@ -673,6 +680,25 @@ fn drain_historical( } } +/// Build the worker's blocking HTTP client. +/// +/// `build()` is fallible — it sets up the TLS trust store, and the blocking +/// client also spawns its own thread and runtime — and `Default` is not a safe +/// fallback: it calls `Client::new`, which panics on exactly those failures. +/// Return `None` instead so the worker can stop without panicking. +fn build_http(options: &ClientOptions) -> Option { + match reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(options.request_timeout_seconds)) + .build() + { + Ok(http) => Some(http), + Err(e) => { + warn!("posthog-rs: failed to build the transport HTTP client: {e:?}"); + None + } + } +} + // =========================================================================== // V1 pipeline // =========================================================================== @@ -705,22 +731,20 @@ struct Pipeline { #[cfg(feature = "capture-v1")] impl Pipeline { - fn new(options: &ClientOptions, clock: Arc, len: Arc) -> Self { - let http = reqwest::blocking::Client::builder() - .timeout(Duration::from_secs(options.request_timeout_seconds)) - .build() - .unwrap_or_default(); + /// `None` when the HTTP client cannot be built, so nothing could be sent. + fn new(options: &ClientOptions, clock: Arc, len: Arc) -> Option { + let http = build_http(options)?; let url = options .endpoints() .build_custom_url(super::v1_capture::V1_CAPTURE_PATH); - Self { + Some(Self { http, options: options.clone(), url, clock, len, retries: VecDeque::new(), - } + }) } fn send_batch( @@ -985,22 +1009,20 @@ struct Pipeline { #[cfg(not(feature = "capture-v1"))] impl Pipeline { - fn new(options: &ClientOptions, clock: Arc, len: Arc) -> Self { - let http = reqwest::blocking::Client::builder() - .timeout(Duration::from_secs(options.request_timeout_seconds)) - .build() - .unwrap_or_default(); + /// `None` when the HTTP client cannot be built, so nothing could be sent. + fn new(options: &ClientOptions, clock: Arc, len: Arc) -> Option { + let http = build_http(options)?; let url_base = options .endpoints() .build_url(crate::endpoints::Endpoint::Batch); - Self { + Some(Self { http, options: options.clone(), url_base, clock, len, retries: VecDeque::new(), - } + }) } fn send_batch( diff --git a/src/local_evaluation.rs b/src/local_evaluation.rs index b8947f38..45875692 100644 --- a/src/local_evaluation.rs +++ b/src/local_evaluation.rs @@ -235,6 +235,18 @@ pub struct LocalEvaluationConfig { pub request_timeout: Duration, } +/// Leave the poller disabled when HTTP initialization fails. +fn http_client_or_warn(result: Result) -> Option { + match result { + Ok(client) => Some(client), + Err(err) => { + // Debug includes the underlying builder error. + warn!(error = ?err, "Failed to build HTTP client; flag polling is disabled"); + None + } + } +} + /// Synchronous poller for feature flag definitions. /// /// Runs a background thread that periodically fetches flag definitions from @@ -244,7 +256,9 @@ pub struct LocalEvaluationConfig { pub struct FlagPoller { config: LocalEvaluationConfig, cache: FlagCache, - client: reqwest::blocking::Client, + /// `None` when the HTTP client could not be built, which leaves the poller + /// unable to fetch anything. + client: Option, stop_signal: Arc, thread_handle: Option>, /// Observability hooks, injected by the client builder before `start`. @@ -256,15 +270,19 @@ pub struct FlagPoller { impl FlagPoller { /// Create a synchronous flag definition poller. /// + /// If the HTTP client cannot be built, logs a warning and leaves polling + /// disabled. [`Self::load_flags`] then returns [`Error::Connection`]. + /// /// # Parameters /// /// - `config`: Credentials, host, polling interval, and request timeout. /// - `cache`: Shared cache updated by the poller. pub fn new(config: LocalEvaluationConfig, cache: FlagCache) -> Self { - let client = reqwest::blocking::Client::builder() - .timeout(config.request_timeout) - .build() - .unwrap(); + let client = http_client_or_warn( + reqwest::blocking::Client::builder() + .timeout(config.request_timeout) + .build(), + ); Self { config, @@ -276,6 +294,13 @@ impl FlagPoller { } } + /// The HTTP client, or [`Error::Connection`] when it could not be built. + fn http(&self) -> Result<&reqwest::blocking::Client, Error> { + self.client.as_ref().ok_or_else(|| { + Error::Connection("HTTP client is unavailable; flag polling is disabled".to_string()) + }) + } + /// Register `on_error` hooks. Called by the client builder before /// [`FlagPoller::start`]; not part of the public flag-poller API. // Only the blocking client (built when `async-client` is off) injects hooks. @@ -289,6 +314,13 @@ impl FlagPoller { /// Performs an initial synchronous load, then refreshes definitions in the /// background until [`FlagPoller::stop`] is called or the poller is dropped. pub fn start(&mut self) { + // Without an HTTP client the thread could only log failures, so keep + // the poller stopped instead of starting it. + let Some(client) = self.client.clone() else { + warn!("No HTTP client; not starting the feature flag poller"); + return; + }; + info!( poll_interval_secs = self.config.poll_interval.as_secs(), "Starting feature flag poller" @@ -306,11 +338,6 @@ impl FlagPoller { let on_error = self.on_error.clone(); let handle = std::thread::spawn(move || { - let client = reqwest::blocking::Client::builder() - .timeout(config.request_timeout) - .build() - .unwrap(); - let mut last_etag: Option = None; loop { @@ -391,7 +418,7 @@ impl FlagPoller { ); let response = match self - .client + .http()? .get(&url) .header( "Authorization", @@ -458,7 +485,9 @@ impl Drop for FlagPoller { pub struct AsyncFlagPoller { config: LocalEvaluationConfig, cache: FlagCache, - client: reqwest::Client, + /// `None` when the HTTP client could not be built, which leaves the poller + /// unable to fetch anything. + client: Option, stop_signal: Arc, task_handle: Option>, is_running: Arc>, @@ -472,15 +501,19 @@ pub struct AsyncFlagPoller { impl AsyncFlagPoller { /// Create an asynchronous flag definition poller. /// + /// If the HTTP client cannot be built, logs a warning and leaves polling + /// disabled. [`Self::load_flags`] then returns [`Error::Connection`]. + /// /// # Parameters /// /// - `config`: Credentials, host, polling interval, and request timeout. /// - `cache`: Shared cache updated by the poller. pub fn new(config: LocalEvaluationConfig, cache: FlagCache) -> Self { - let client = reqwest::Client::builder() - .timeout(config.request_timeout) - .build() - .unwrap(); + let client = http_client_or_warn( + reqwest::Client::builder() + .timeout(config.request_timeout) + .build(), + ); Self { config, @@ -493,6 +526,13 @@ impl AsyncFlagPoller { } } + /// The HTTP client, or [`Error::Connection`] when it could not be built. + fn http(&self) -> Result<&reqwest::Client, Error> { + self.client.as_ref().ok_or_else(|| { + Error::Connection("HTTP client is unavailable; flag polling is disabled".to_string()) + }) + } + /// Register `on_error` hooks. Called by the client builder before /// [`AsyncFlagPoller::start`]; not part of the public flag-poller API. pub(crate) fn set_on_error(&mut self, hooks: Vec) { @@ -505,6 +545,13 @@ impl AsyncFlagPoller { /// background until [`AsyncFlagPoller::stop`] is called or the poller is /// dropped. pub async fn start(&mut self) { + // Without an HTTP client the task could only log failures, so keep the + // poller stopped instead of starting it. + let Some(client) = self.client.clone() else { + warn!("No HTTP client; not starting the feature flag poller"); + return; + }; + // Check if already running { let mut is_running = self.is_running.write().await; @@ -530,7 +577,6 @@ impl AsyncFlagPoller { let cache = self.cache.clone(); let stop_signal = self.stop_signal.clone(); let is_running = self.is_running.clone(); - let client = self.client.clone(); let on_error = self.on_error.clone(); let task = tokio::spawn(async move { @@ -621,7 +667,7 @@ impl AsyncFlagPoller { ); let response = match self - .client + .http()? .get(&url) .header( "Authorization", @@ -934,6 +980,43 @@ mod tests { } } + fn poller_config() -> LocalEvaluationConfig { + LocalEvaluationConfig { + personal_api_key: "phx_test".to_string(), + project_api_key: "phc_test".to_string(), + api_host: "http://localhost:1".to_string(), + poll_interval: Duration::from_secs(30), + request_timeout: Duration::from_secs(1), + } + } + + #[test] + fn a_poller_without_an_http_client_reports_an_error_and_does_not_start() { + let mut poller = FlagPoller::new(poller_config(), FlagCache::new()); + poller.client = None; + + assert!(matches!(poller.load_flags(), Err(Error::Connection(_)))); + + poller.start(); + assert!(poller.thread_handle.is_none()); + } + + #[cfg(feature = "async-client")] + #[tokio::test] + async fn an_async_poller_without_an_http_client_reports_an_error_and_does_not_start() { + let mut poller = AsyncFlagPoller::new(poller_config(), FlagCache::new()); + poller.client = None; + + assert!(matches!( + poller.load_flags().await, + Err(Error::Connection(_)) + )); + + poller.start().await; + assert!(poller.task_handle.is_none()); + assert!(!poller.is_running().await); + } + #[test] fn evaluated_details_survive_a_cache_refresh() { let cache = FlagCache::new(); diff --git a/tests/test_http_client_failure.rs b/tests/test_http_client_failure.rs new file mode 100644 index 00000000..3c3d4a1d --- /dev/null +++ b/tests/test_http_client_failure.rs @@ -0,0 +1,164 @@ +//! Exercise real TLS initialization failures through the public constructors. +//! Linux uses SSL_CERT_FILE / SSL_CERT_DIR for its platform trust store. +#![cfg(target_os = "linux")] + +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use httpmock::prelude::*; +use posthog_rs::{ + ClientOptionsBuilder, Error, Event, FlagCache, FlagPoller, LocalEvaluationConfig, +}; + +/// Isolate the empty trust store in a subprocess so parallel tests keep their +/// normal certificates. The mock server stays in the parent for the same reason. +fn without_ca_certificates(test_name: &str, test: impl FnOnce(String)) { + if std::env::var("POSTHOG_RS_EMPTY_CA_TEST").as_deref() == Ok(test_name) { + let error = reqwest::Client::builder() + .build() + .expect_err("the empty trust store must cause an HTTP builder error"); + assert!(format!("{error:?}").contains("No CA certificates were loaded")); + tracing_subscriber::fmt() + .with_ansi(false) + .with_max_level(tracing::Level::WARN) + .with_writer(std::io::stderr) + .init(); + test(std::env::var("POSTHOG_RS_EMPTY_CA_HOST").expect("parent mock server URL")); + return; + } + + let server = MockServer::start(); + let requests = server.mock(|_when, then| { + then.status(200).json_body(serde_json::json!({})); + }); + let empty_dir = std::env::temp_dir().join(format!("posthog-empty-ca-{}", uuid::Uuid::now_v7())); + std::fs::create_dir(&empty_dir).expect("create empty certificate directory"); + let mut child = Command::new(std::env::current_exe().expect("integration test executable")) + .args(["--exact", test_name, "--nocapture"]) + .env("POSTHOG_RS_EMPTY_CA_TEST", test_name) + .env("POSTHOG_RS_EMPTY_CA_HOST", server.base_url()) + .env("SSL_CERT_FILE", "/dev/null") + .env("SSL_CERT_DIR", &empty_dir) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("start empty-trust-store subprocess"); + + let deadline = Instant::now() + Duration::from_secs(30); + while child.try_wait().expect("check subprocess status").is_none() { + if Instant::now() >= deadline { + child.kill().expect("terminate stalled subprocess"); + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + let output = child.wait_with_output().expect("collect subprocess output"); + std::fs::remove_dir(&empty_dir).expect("remove certificate directory"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "empty-trust-store subprocess failed: {}\n{}\n{stderr}", + output.status, + String::from_utf8_lossy(&output.stdout), + ); + assert!( + stderr.contains("No CA certificates were loaded"), + "warning must include the builder error's cause: {}", + stderr, + ); + requests.assert_calls(0); +} + +#[cfg(feature = "async-client")] +fn complete(operation: impl std::future::Future) -> T { + futures::executor::block_on(operation) +} + +#[cfg(not(feature = "async-client"))] +fn complete(result: T) -> T { + result +} + +#[test] +fn client_without_ca_certificates_is_noop() { + without_ca_certificates("client_without_ca_certificates_is_noop", |host| { + for local_evaluation in [false, true] { + let options = ClientOptionsBuilder::default() + .api_key("phc_test".to_string()) + .secret_key("phx_test") + .host(host.clone()) + .enable_local_evaluation(local_evaluation) + .request_timeout_seconds(1u64) + .build() + .expect("valid client options"); + assert!(!options.is_disabled()); + let client = complete(posthog_rs::client(options)); + let event = Event::new("test_event", "test_user"); + client.capture(event.clone()); + client.capture_batch(vec![event.clone()], false); + client.capture_batch(vec![event.clone()], true); + let summary = complete(client.capture_immediate(event)) + .expect("disabled capture should succeed without sending"); + assert_eq!(summary.submitted(), 0); + + let (flags, payloads) = + complete(client.get_feature_flags("test_user".to_string(), None, None, None)) + .expect("disabled flags should return empty results"); + assert!(flags.is_empty()); + assert!(payloads.is_empty()); + #[allow(deprecated)] + let payload = complete(client.get_feature_flag_payload("test_flag", "test_user")) + .expect("disabled payload request should succeed"); + assert_eq!(payload, None); + let evaluations = complete(client.evaluate_flags("test_user", Default::default())) + .expect("disabled evaluation should succeed"); + assert_eq!(evaluations.get_flag("test_flag"), None); + + complete(client.flush()); + complete(client.shutdown()); + complete(client.shutdown()); + } + }); +} + +fn poller_config(host: String) -> LocalEvaluationConfig { + LocalEvaluationConfig { + personal_api_key: "phx_test".to_string(), + project_api_key: "phc_test".to_string(), + api_host: host, + poll_interval: Duration::from_secs(30), + request_timeout: Duration::from_secs(1), + } +} + +#[test] +fn blocking_poller_without_ca_certificates_stays_stopped() { + without_ca_certificates( + "blocking_poller_without_ca_certificates_stays_stopped", + |host| { + let mut poller = FlagPoller::new(poller_config(host), FlagCache::new()); + assert!(matches!(poller.load_flags(), Err(Error::Connection(_)))); + poller.start(); + poller.stop(); + }, + ); +} + +#[cfg(feature = "async-client")] +#[test] +fn async_poller_without_ca_certificates_stays_stopped() { + without_ca_certificates( + "async_poller_without_ca_certificates_stays_stopped", + |host| { + let mut poller = + posthog_rs::AsyncFlagPoller::new(poller_config(host), FlagCache::new()); + assert!(matches!( + complete(poller.load_flags()), + Err(Error::Connection(_)) + )); + complete(poller.start()); + assert!(!complete(poller.is_running())); + complete(poller.stop()); + }, + ); +}