From 77c1d1ba93f0f897356d42cd6be6423be13a13ea Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:39:32 +0000 Subject: [PATCH 1/5] fix(client): do not panic when the HTTP client cannot be built reqwest's build() is fallible because it sets up the TLS trust store. In a container without CA certificates it returns an error, and the unwrap stopped the calling program. Both clients now log a warning, disable themselves, and return a no-op client instead. Generated-By: PostHog Desktop Task-Id: e3125f9b-fea1-42ef-83b2-4e8bcbddc7bb --- .sampo/changesets/quiet-otters-degrade.md | 5 +++ src/client/async_client.rs | 39 +++++++++++++++-------- src/client/blocking.rs | 39 +++++++++++++++-------- src/client/common.rs | 38 +++++++++++++++++++++- 4 files changed, 92 insertions(+), 29 deletions(-) create mode 100644 .sampo/changesets/quiet-otters-degrade.md diff --git a/.sampo/changesets/quiet-otters-degrade.md b/.sampo/changesets/quiet-otters-degrade.md new file mode 100644 index 00000000..d14093e4 --- /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. diff --git a/src/client/async_client.rs b/src/client/async_client.rs index 102b2185..b7acfd6e 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>, @@ -127,11 +127,13 @@ 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. 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 +180,15 @@ pub async fn client>(options: C) -> Client { } impl Client { + /// Borrow the HTTP client. `None` when the client could not be built, in + /// which case the client is disabled and callers must not reach the + /// network. + 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..e1348f78 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>, @@ -131,11 +131,13 @@ 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. 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 +184,15 @@ pub fn client>(options: C) -> Client { } impl Client { + /// Borrow the HTTP client. `None` when the client could not be built, in + /// which case the client is disabled and callers must not reach the + /// network. + 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..d83ee65e 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,24 @@ fn normalize_payload(payload: serde_json::Value) -> serde_json::Value { } } +/// Take the result of building the HTTP client. On failure, log a warning and +/// disable `options` so the SDK degrades to a no-op client instead of +/// panicking. TLS trust-store setup makes `build()` fallible: a container +/// without CA certificates fails here. +pub(super) fn http_client_or_disable( + result: Result, + options: &mut ClientOptions, +) -> Option { + match result { + Ok(client) => Some(client), + Err(err) => { + warn!("Failed to build HTTP client ({err}); disabling PostHog client"); + options.disabled = true; + None + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -493,4 +512,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()); + } } From f8535c780c10e8d5e76f87f37f61ab957691794b Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:04:19 +0000 Subject: [PATCH 2/5] fix(transport): stop the worker when its HTTP client cannot be built The capture worker built its blocking reqwest client with unwrap_or_default(), but Default for reqwest::blocking::Client calls Client::new(), which panics on the same failures build() reports. The blocking builder can also fail where the client's async build succeeded, because it spawns its own thread and runtime. Pipeline::new now returns None on a build failure and logs a warning, and run_worker signals any queued completions and returns instead of panicking on a thread whose panic is swallowed by join(). Generated-By: PostHog Desktop Task-Id: ef58b299-44ac-4dc4-be2e-66379d7c883a --- src/client/transport.rs | 52 +++++++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/src/client/transport.rs b/src/client/transport.rs index 1b0e24b9..657a4e79 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( From b5ae05c551d84ad7536c8f94e6b937b9812f81bf Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:14:22 +0000 Subject: [PATCH 3/5] fix(flags): do not panic when a poller's HTTP client cannot be built MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FlagPoller::new and AsyncFlagPoller::new unwrapped the reqwest builder, so a direct user of either public poller still panicked in the container this PR is about — one without CA certificates, where the TLS trust-store setup makes build() fail. Both pollers now hold an Option client: a failed build logs a warning, load_flags returns Error::Connection, and start keeps the poller stopped instead of running a thread or task that could only log failures. The blocking poller's polling thread reuses the client built in new instead of building a second one, which removes the third unwrap; the async poller already cloned its client that way. Generated-By: PostHog Desktop Task-Id: 6b381563-d746-41ae-94d3-5fb69dd64ec9 --- .sampo/changesets/quiet-otters-degrade.md | 2 +- src/local_evaluation.rs | 115 ++++++++++++++++++---- 2 files changed, 98 insertions(+), 19 deletions(-) diff --git a/.sampo/changesets/quiet-otters-degrade.md b/.sampo/changesets/quiet-otters-degrade.md index d14093e4..2084976d 100644 --- a/.sampo/changesets/quiet-otters-degrade.md +++ b/.sampo/changesets/quiet-otters-degrade.md @@ -2,4 +2,4 @@ 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. +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/local_evaluation.rs b/src/local_evaluation.rs index b8947f38..3eaedc42 100644 --- a/src/local_evaluation.rs +++ b/src/local_evaluation.rs @@ -235,6 +235,20 @@ pub struct LocalEvaluationConfig { pub request_timeout: Duration, } +/// Take the result of building a poller's HTTP client. On failure, log a +/// warning and return `None` so the poller degrades to doing nothing instead +/// of panicking. TLS trust-store setup makes `build()` fallible: a container +/// without CA certificates fails here. +fn http_client_or_warn(result: Result) -> Option { + match result { + Ok(client) => Some(client), + Err(err) => { + 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 +258,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`. @@ -261,10 +277,11 @@ impl FlagPoller { /// - `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 +293,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 +313,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 +337,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 +417,7 @@ impl FlagPoller { ); let response = match self - .client + .http()? .get(&url) .header( "Authorization", @@ -458,7 +484,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>, @@ -477,10 +505,11 @@ impl AsyncFlagPoller { /// - `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 +522,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 +541,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 +573,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 +663,7 @@ impl AsyncFlagPoller { ); let response = match self - .client + .http()? .get(&url) .header( "Authorization", @@ -934,6 +976,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(); From e084951b284189c7c5e496f2314883a8231a4fe0 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:17:23 +0000 Subject: [PATCH 4/5] fix(client): log the cause of an HTTP client build failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reqwest's Display for a builder failure writes the fixed text "builder error" and nothing else, so the warnings this PR adds in place of the panic said only that the build failed. Verified against the resolved reqwest 0.13.4 (Display writes the kind, Debug adds the source) and reproduced with an empty trust store: Display gives "builder error", Debug gives reqwest::Error { kind: Builder, source: General("No CA certificates were loaded from the system") }. The three build-failure warnings — the shared client helper, the transport worker, and the flag pollers — now format the error with Debug, which prints the source the operator needs. The helpers take a Debug bound instead of Display; every caller already passes a reqwest::Error or a &str. Generated-By: PostHog Desktop Task-Id: 6b381563-d746-41ae-94d3-5fb69dd64ec9 --- src/client/common.rs | 8 ++++++-- src/client/transport.rs | 2 +- src/local_evaluation.rs | 7 +++++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/client/common.rs b/src/client/common.rs index d83ee65e..834f4fa7 100644 --- a/src/client/common.rs +++ b/src/client/common.rs @@ -341,14 +341,18 @@ fn normalize_payload(payload: serde_json::Value) -> serde_json::Value { /// disable `options` so the SDK degrades to a no-op client instead of /// panicking. TLS trust-store setup makes `build()` fallible: a container /// without CA certificates fails here. -pub(super) fn http_client_or_disable( +pub(super) fn http_client_or_disable( result: Result, options: &mut ClientOptions, ) -> Option { match result { Ok(client) => Some(client), Err(err) => { - warn!("Failed to build HTTP client ({err}); disabling PostHog client"); + // Debug, not Display: `reqwest::Error` displays a builder failure + // as the bare text `builder error` and keeps the cause — the + // missing certificates, say — in its source, which only Debug + // prints. + warn!("Failed to build HTTP client ({err:?}); disabling PostHog client"); options.disabled = true; None } diff --git a/src/client/transport.rs b/src/client/transport.rs index 657a4e79..6876851c 100644 --- a/src/client/transport.rs +++ b/src/client/transport.rs @@ -693,7 +693,7 @@ fn build_http(options: &ClientOptions) -> Option { { Ok(http) => Some(http), Err(e) => { - warn!("posthog-rs: failed to build the transport HTTP client: {e}"); + warn!("posthog-rs: failed to build the transport HTTP client: {e:?}"); None } } diff --git a/src/local_evaluation.rs b/src/local_evaluation.rs index 3eaedc42..08ce5893 100644 --- a/src/local_evaluation.rs +++ b/src/local_evaluation.rs @@ -239,11 +239,14 @@ pub struct LocalEvaluationConfig { /// warning and return `None` so the poller degrades to doing nothing instead /// of panicking. TLS trust-store setup makes `build()` fallible: a container /// without CA certificates fails here. -fn http_client_or_warn(result: Result) -> Option { +fn http_client_or_warn(result: Result) -> Option { match result { Ok(client) => Some(client), Err(err) => { - warn!(error = %err, "Failed to build HTTP client; flag polling is disabled"); + // Debug, not Display: `reqwest::Error` displays a builder failure + // as the bare text `builder error` and keeps the cause in its + // source, which only Debug prints. + warn!(error = ?err, "Failed to build HTTP client; flag polling is disabled"); None } } From 0ced10ef47685f7705bce7e0a38f08e7b5cfde4b Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Mon, 14 Sep 2026 13:24:02 -0400 Subject: [PATCH 5/5] test(client): cover HTTP initialization without CA certificates --- src/client/async_client.rs | 6 +- src/client/blocking.rs | 6 +- src/client/common.rs | 11 +- src/local_evaluation.rs | 15 +-- tests/test_http_client_failure.rs | 164 ++++++++++++++++++++++++++++++ 5 files changed, 181 insertions(+), 21 deletions(-) create mode 100644 tests/test_http_client_failure.rs diff --git a/src/client/async_client.rs b/src/client/async_client.rs index b7acfd6e..6af727e6 100644 --- a/src/client/async_client.rs +++ b/src/client/async_client.rs @@ -126,6 +126,8 @@ 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 mut options = options.into().sanitize(); let client = http_client_or_disable( @@ -180,9 +182,7 @@ pub async fn client>(options: C) -> Client { } impl Client { - /// Borrow the HTTP client. `None` when the client could not be built, in - /// which case the client is disabled and callers must not reach the - /// network. + /// 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()) diff --git a/src/client/blocking.rs b/src/client/blocking.rs index e1348f78..e6b129ce 100644 --- a/src/client/blocking.rs +++ b/src/client/blocking.rs @@ -130,6 +130,8 @@ 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 mut options = options.into().sanitize(); let client = http_client_or_disable( @@ -184,9 +186,7 @@ pub fn client>(options: C) -> Client { } impl Client { - /// Borrow the HTTP client. `None` when the client could not be built, in - /// which case the client is disabled and callers must not reach the - /// network. + /// 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()) diff --git a/src/client/common.rs b/src/client/common.rs index 834f4fa7..88f6b904 100644 --- a/src/client/common.rs +++ b/src/client/common.rs @@ -337,10 +337,8 @@ fn normalize_payload(payload: serde_json::Value) -> serde_json::Value { } } -/// Take the result of building the HTTP client. On failure, log a warning and -/// disable `options` so the SDK degrades to a no-op client instead of -/// panicking. TLS trust-store setup makes `build()` fallible: a container -/// without CA certificates fails here. +/// 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, @@ -348,10 +346,7 @@ pub(super) fn http_client_or_disable( match result { Ok(client) => Some(client), Err(err) => { - // Debug, not Display: `reqwest::Error` displays a builder failure - // as the bare text `builder error` and keeps the cause — the - // missing certificates, say — in its source, which only Debug - // prints. + // Debug includes the underlying builder error. warn!("Failed to build HTTP client ({err:?}); disabling PostHog client"); options.disabled = true; None diff --git a/src/local_evaluation.rs b/src/local_evaluation.rs index 08ce5893..45875692 100644 --- a/src/local_evaluation.rs +++ b/src/local_evaluation.rs @@ -235,17 +235,12 @@ pub struct LocalEvaluationConfig { pub request_timeout: Duration, } -/// Take the result of building a poller's HTTP client. On failure, log a -/// warning and return `None` so the poller degrades to doing nothing instead -/// of panicking. TLS trust-store setup makes `build()` fallible: a container -/// without CA certificates fails here. +/// 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, not Display: `reqwest::Error` displays a builder failure - // as the bare text `builder error` and keeps the cause in its - // source, which only Debug prints. + // Debug includes the underlying builder error. warn!(error = ?err, "Failed to build HTTP client; flag polling is disabled"); None } @@ -275,6 +270,9 @@ 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. @@ -503,6 +501,9 @@ 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. 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()); + }, + ); +}