1use std::collections::{HashMap, HashSet};
2#[cfg(feature = "error-tracking")]
3use std::error::Error as StdError;
4use std::sync::{Arc, OnceLock};
5use std::time::Duration;
6
7use reqwest::{header::CONTENT_TYPE, header::USER_AGENT, Client as HttpClient};
8use serde_json::json;
9use tracing::{debug, instrument, trace, warn};
10
11use super::get_default_user_agent;
12use crate::endpoints::Endpoint;
13#[cfg(feature = "error-tracking")]
14use crate::error_tracking::{build_exception_event, CaptureExceptionOptions};
15use crate::feature_flag_evaluations::{
16 EvaluateFlagsOptions, EvaluatedFlagRecord, FeatureFlagEvaluations, FeatureFlagEvaluationsHost,
17 FlagCalledEventParams,
18};
19use crate::feature_flags::{match_feature_flag, FeatureFlag, FeatureFlagsResponse, FlagValue};
20use crate::local_evaluation::{AsyncFlagPoller, FlagCache, LocalEvaluationConfig, LocalEvaluator};
21use crate::{Error, Event};
22
23fn is_retryable_feature_flags_error(err: &reqwest::Error) -> bool {
24 if err.is_timeout() {
25 return true;
26 }
27
28 let mut source = std::error::Error::source(err);
29 while let Some(error) = source {
30 if let Some(io_error) = error.downcast_ref::<std::io::Error>() {
31 return matches!(
32 io_error.kind(),
33 std::io::ErrorKind::ConnectionReset
34 | std::io::ErrorKind::TimedOut
35 | std::io::ErrorKind::UnexpectedEof
36 );
37 }
38 source = std::error::Error::source(error);
39 }
40
41 !err.to_string()
42 .to_lowercase()
43 .contains("connection refused")
44}
45
46use super::common::{
47 already_reported, build_dedup_key, extract_flag_details, flag_called_event,
48 flag_event_dedup_cache, local_record, remote_record_from_detail, report_flags_error,
49 DetailedFlagsResponse, FlagEventDedupCache,
50};
51use super::transport::{Completion, Control, TransportHandle};
52use super::{CaptureSummary, ClientOptions};
53#[cfg(not(feature = "capture-v1"))]
54use reqwest::header::CONTENT_ENCODING;
55
56pub struct Client {
58 options: ClientOptions,
59 client: HttpClient,
60 local_evaluator: Option<LocalEvaluator>,
61 _flag_poller: Option<AsyncFlagPoller>,
62 flag_event_host: OnceLock<Arc<dyn FeatureFlagEvaluationsHost>>,
63 transport: Option<Arc<TransportHandle>>,
65}
66
67struct AsyncFlagEventHost {
71 options: ClientOptions,
72 transport: Option<Arc<TransportHandle>>,
73 dedup_cache: FlagEventDedupCache,
74}
75
76impl AsyncFlagEventHost {
77 fn from_options(options: &ClientOptions, transport: Option<Arc<TransportHandle>>) -> Self {
78 Self {
79 options: options.clone(),
80 transport,
81 dedup_cache: flag_event_dedup_cache(),
82 }
83 }
84
85 fn enqueue(&self, event: Event) {
86 if let Some(transport) = &self.transport {
87 transport.enqueue(event);
88 }
89 }
90}
91
92impl FeatureFlagEvaluationsHost for AsyncFlagEventHost {
93 fn capture_flag_called_event_if_needed(&self, params: FlagCalledEventParams) {
94 let dedup_key = build_dedup_key(¶ms.key, params.response.as_ref(), ¶ms.groups);
95 if already_reported(&self.dedup_cache, ¶ms.distinct_id, &dedup_key) {
96 return;
97 }
98
99 if let Some(event) =
100 flag_called_event(params, self.options.disable_geoip, self.options.is_server)
101 {
102 self.enqueue(event);
103 }
104 }
105
106 fn log_warning(&self, message: &str) {
107 warn!("{message}");
110 }
111}
112
113pub async fn client<C: Into<ClientOptions>>(options: C) -> Client {
129 let options = options.into().sanitize();
130 let client = HttpClient::builder()
131 .timeout(Duration::from_secs(options.request_timeout_seconds))
132 .build()
133 .unwrap(); let (local_evaluator, flag_poller) =
136 if options.enable_local_evaluation && !options.is_disabled() {
137 if let Some(ref secret_key) = options.secret_key {
138 let cache = FlagCache::new();
139
140 let config = LocalEvaluationConfig {
141 personal_api_key: secret_key.clone(),
142 project_api_key: options.api_key.clone(),
143 api_host: options.endpoints().api_host(),
144 poll_interval: Duration::from_secs(options.poll_interval_seconds),
145 request_timeout: Duration::from_secs(options.request_timeout_seconds),
146 };
147
148 let mut poller = AsyncFlagPoller::new(config, cache.clone());
149 poller.set_on_error(options.on_error.clone());
150 poller.start().await;
151
152 (Some(LocalEvaluator::new(cache)), Some(poller))
153 } else {
154 warn!(
155 "Local evaluation enabled but secret_key not set, falling back to API evaluation"
156 );
157 (None, None)
158 }
159 } else {
160 (None, None)
161 };
162
163 let transport = if options.is_disabled() {
164 None
165 } else {
166 Some(Arc::new(TransportHandle::spawn(options.clone())))
167 };
168
169 Client {
170 options,
171 client,
172 local_evaluator,
173 _flag_poller: flag_poller,
174 flag_event_host: OnceLock::new(),
175 transport,
176 }
177}
178
179impl Client {
180 #[instrument(skip(self, event), level = "debug")]
195 pub fn capture(&self, event: Event) {
196 if let Some(transport) = &self.transport {
197 transport.enqueue(event);
198 }
199 }
200
201 pub async fn flush(&self) {
205 let Some(transport) = &self.transport else {
206 return;
207 };
208 if transport.is_closed() {
209 return;
210 }
211 let (tx, rx) = tokio::sync::oneshot::channel();
212 if transport.send_control(Control::Flush(Completion::Async(tx))) {
213 let _ = rx.await;
214 }
215 }
216
217 #[cfg(feature = "error-tracking")]
220 pub(crate) fn is_disabled(&self) -> bool {
221 self.options.is_disabled()
222 }
223
224 #[cfg(feature = "error-tracking")]
227 pub(crate) fn error_tracking_options(&self) -> &crate::error_tracking::ErrorTrackingOptions {
228 self.options.error_tracking()
229 }
230
231 #[cfg(test)]
235 pub(crate) fn flush_blocking(&self) {
236 if let Some(transport) = &self.transport {
237 transport.flush_blocking();
238 }
239 }
240
241 #[cfg(feature = "error-tracking")]
245 pub(crate) fn flush_blocking_timeout(&self, timeout: Duration) {
246 if let Some(transport) = &self.transport {
247 transport.flush_blocking_timeout(timeout);
248 }
249 }
250
251 #[cfg(feature = "error-tracking")]
254 pub(crate) fn on_transport_worker(&self) -> bool {
255 self.transport
256 .as_ref()
257 .is_some_and(|t| t.on_worker_thread())
258 }
259
260 #[cfg(feature = "error-tracking")]
265 pub(crate) fn enqueue_panic_event(&self, event: Event) {
266 if let Some(transport) = &self.transport {
267 transport.enqueue_panic(event);
268 }
269 }
270
271 pub async fn shutdown(&self) {
275 let Some(transport) = &self.transport else {
276 return;
277 };
278 if transport.begin_close() {
279 let (tx, rx) = tokio::sync::oneshot::channel();
280 if transport.send_control(Control::Shutdown(Completion::Async(tx))) {
281 let _ = rx.await;
282 }
283 }
284 transport.join();
289 }
290
291 #[cfg(feature = "error-tracking")]
317 pub async fn capture_exception<E>(&self, error: &E) -> Result<(), Error>
318 where
319 E: StdError + ?Sized,
320 {
321 self.capture_exception_with(error, CaptureExceptionOptions::default())
322 .await
323 }
324
325 #[cfg(feature = "error-tracking")]
352 pub async fn capture_exception_with<E>(
353 &self,
354 error: &E,
355 options: CaptureExceptionOptions,
356 ) -> Result<(), Error>
357 where
358 E: StdError + ?Sized,
359 {
360 if self.options.is_disabled() {
361 trace!("Client is disabled, skipping exception capture");
362 return Ok(());
363 }
364
365 self.capture(build_exception_event(
366 error,
367 options,
368 self.options.error_tracking(),
369 )?);
370 Ok(())
371 }
372
373 pub fn capture_batch(&self, events: Vec<Event>, historical_migration: bool) {
389 if let Some(transport) = &self.transport {
390 if historical_migration {
391 transport.enqueue_historical(events);
392 } else {
393 for event in events {
394 transport.enqueue(event);
395 }
396 }
397 }
398 }
399
400 #[must_use = "the delivery outcome should be inspected"]
415 pub async fn capture_immediate(&self, event: Event) -> Result<CaptureSummary, Error> {
416 self.capture_batch_immediate(vec![event], false).await
417 }
418
419 #[must_use = "the delivery outcome should be inspected"]
446 #[instrument(
447 skip(self, events),
448 fields(event_count = events.len(), historical_migration),
449 level = "debug"
450 )]
451 pub async fn capture_batch_immediate(
452 &self,
453 events: Vec<Event>,
454 historical_migration: bool,
455 ) -> Result<CaptureSummary, Error> {
456 if self.options.is_disabled() || events.is_empty() {
457 return Ok(CaptureSummary::default());
458 }
459 self.send_immediate(events, historical_migration).await
460 }
461
462 #[cfg(feature = "capture-v1")]
466 async fn send_immediate(
467 &self,
468 events: Vec<Event>,
469 historical_migration: bool,
470 ) -> Result<CaptureSummary, Error> {
471 use super::v1_capture::{self, Step};
472
473 let Some(mut prep) =
474 v1_capture::prepare_immediate(&self.options, events, historical_migration)
475 else {
476 return Ok(CaptureSummary::default());
477 };
478 let mut final_results = HashMap::new();
479 let mut attempt: u32 = 1;
480
481 loop {
482 let (headers, body) = v1_capture::build_attempt_parts(
483 &self.options,
484 &prep.request_id,
485 attempt,
486 &prep.created_at,
487 prep.historical_migration,
488 &prep.pending,
489 )?;
490
491 let step = match self
492 .client
493 .post(&prep.url)
494 .headers(headers)
495 .body(body)
496 .send()
497 .await
498 {
499 Err(e) => v1_capture::after_transport_error(
500 &self.options,
501 &prep.request_id,
502 attempt,
503 e.to_string(),
504 ),
505 Ok(response) => {
506 let status = response.status().as_u16();
507 let retry_after = v1_capture::parse_retry_after(response.headers());
508 let text = response
509 .text()
510 .await
511 .unwrap_or_else(|_| "Unknown error".to_string());
512 v1_capture::after_response(
513 &self.options,
514 &prep.request_id,
515 attempt,
516 status,
517 retry_after,
518 &text,
519 &mut prep.pending,
520 &mut final_results,
521 )
522 }
523 };
524
525 match step {
526 Step::Done => {
527 return Ok(CaptureSummary::from_results(prep.submitted, final_results))
528 }
529 Step::Fail(e) => return Err(e),
530 Step::Backoff(delay) => {
531 attempt += 1;
532 tokio::time::sleep(delay).await;
533 }
534 }
535 }
536 }
537
538 #[cfg(not(feature = "capture-v1"))]
541 async fn send_immediate(
542 &self,
543 events: Vec<Event>,
544 historical_migration: bool,
545 ) -> Result<CaptureSummary, Error> {
546 use super::retry::{self, v0_after_response, v0_after_transport_error, Step};
547 use super::v0_capture;
548
549 let Some(prep) =
550 v0_capture::prepare_immediate(&self.options, events, historical_migration)?
551 else {
552 return Ok(CaptureSummary::default());
553 };
554
555 let mut attempt: u32 = 1;
556 loop {
557 let mut request = self
558 .client
559 .post(&prep.url)
560 .header(CONTENT_TYPE, "application/json")
561 .header(USER_AGENT, get_default_user_agent())
562 .body(prep.body.clone());
563 if let Some(token) = prep.encoding {
564 request = request.header(CONTENT_ENCODING, token);
565 }
566 #[cfg(feature = "test-harness")]
567 if let Some(ref extra) = self.options.extra_capture_headers {
568 for (k, v) in extra {
569 request = request.header(k.as_str(), v.as_str());
570 }
571 }
572
573 let step = match request.send().await {
574 Err(e) => v0_after_transport_error(&self.options, attempt, e.to_string()),
575 Ok(response) => {
576 let status = response.status().as_u16();
577 let retry_after = retry::parse_retry_after(response.headers());
578 let text = response
579 .text()
580 .await
581 .unwrap_or_else(|_| "Unknown error".to_string());
582 v0_after_response(&self.options, attempt, status, retry_after, &text)
583 }
584 };
585
586 match step {
587 Step::Done => return Ok(CaptureSummary::delivered(prep.kept)),
588 Step::Fail(e) => return Err(e),
589 Step::Backoff(delay) => {
590 attempt += 1;
591 tokio::time::sleep(delay).await;
592 }
593 }
594 }
595 }
596
597 #[cfg(feature = "test-harness")]
604 pub fn pending_events(&self) -> usize {
605 self.transport.as_ref().map_or(0, |t| t.pending())
606 }
607
608 #[must_use = "feature flags result should be used"]
634 pub async fn get_feature_flags<S: Into<String>>(
635 &self,
636 distinct_id: S,
637 groups: Option<HashMap<String, String>>,
638 person_properties: Option<HashMap<String, serde_json::Value>>,
639 group_properties: Option<HashMap<String, HashMap<String, serde_json::Value>>>,
640 ) -> Result<
641 (
642 HashMap<String, FlagValue>,
643 HashMap<String, serde_json::Value>,
644 ),
645 Error,
646 > {
647 if self.options.is_disabled() {
648 trace!("Client is disabled, skipping feature flags request");
649 return Ok((HashMap::new(), HashMap::new()));
650 }
651
652 let flags_endpoint = self.options.endpoints().build_url(Endpoint::Flags);
653
654 let mut payload = json!({
655 "api_key": self.options.api_key,
656 "distinct_id": distinct_id.into(),
657 });
658
659 if let Some(groups) = groups {
660 payload["groups"] = json!(groups);
661 }
662
663 if let Some(person_properties) = person_properties {
664 payload["person_properties"] = json!(person_properties);
665 }
666
667 if let Some(group_properties) = group_properties {
668 payload["group_properties"] = json!(group_properties);
669 }
670
671 if self.options.disable_geoip {
673 payload["disable_geoip"] = json!(true);
674 }
675
676 let response = self
677 .send_feature_flags_request(&flags_endpoint, &payload)
678 .await?;
679
680 let distinct_id = payload.get("distinct_id").and_then(|v| v.as_str());
681 if !response.status().is_success() {
682 let status = response.status();
683 let text = response
684 .text()
685 .await
686 .unwrap_or_else(|_| "Unknown error".to_string());
687 let err = Error::Connection(format!("API request failed with status {status}: {text}"));
688 report_flags_error(
689 &self.options.on_error,
690 &flags_endpoint,
691 distinct_id,
692 Some(status.as_u16()),
693 Some(&text),
694 &err,
695 );
696 return Err(err);
697 }
698
699 let status = response.status().as_u16();
700 let flags_response = match response.json::<FeatureFlagsResponse>().await {
701 Ok(r) => r,
702 Err(e) => {
703 let err =
704 Error::Serialization(format!("Failed to parse feature flags response: {e}"));
705 report_flags_error(
706 &self.options.on_error,
707 &flags_endpoint,
708 distinct_id,
709 Some(status),
710 None,
711 &err,
712 );
713 return Err(err);
714 }
715 };
716
717 Ok(flags_response.normalize())
718 }
719
720 #[must_use = "feature flag result should be used"]
741 #[instrument(skip_all, level = "debug")]
742 #[deprecated(
743 since = "0.6.0",
744 note = "Use Client::evaluate_flags() to fetch a snapshot, then call .get_flag(key) on it. \
745 The snapshot deduplicates $feature_flag_called events and supports attaching \
746 rich metadata to captured events via Event::with_flags()."
747 )]
748 pub async fn get_feature_flag<K: Into<String>, D: Into<String>>(
749 &self,
750 key: K,
751 distinct_id: D,
752 groups: Option<HashMap<String, String>>,
753 person_properties: Option<HashMap<String, serde_json::Value>>,
754 group_properties: Option<HashMap<String, HashMap<String, serde_json::Value>>>,
755 ) -> Result<Option<FlagValue>, Error> {
756 let key_str = key.into();
757 let distinct_id_str = distinct_id.into();
758
759 if let Some(ref evaluator) = self.local_evaluator {
761 let empty_props = HashMap::new();
762 let empty_groups: HashMap<String, String> = HashMap::new();
763 let empty_group_props: HashMap<String, HashMap<String, serde_json::Value>> =
764 HashMap::new();
765 let mut local_props;
766 let props = if let Some(props) = person_properties.as_ref() {
767 local_props = props.clone();
768 local_props
769 .entry("distinct_id".to_string())
770 .or_insert_with(|| json!(distinct_id_str.clone()));
771 &local_props
772 } else {
773 local_props = empty_props;
774 local_props.insert("distinct_id".to_string(), json!(distinct_id_str.clone()));
775 &local_props
776 };
777 let groups_ref = groups.as_ref().unwrap_or(&empty_groups);
778 let group_props_ref = group_properties.as_ref().unwrap_or(&empty_group_props);
779 match evaluator.evaluate_flag(
780 &key_str,
781 &distinct_id_str,
782 props,
783 groups_ref,
784 group_props_ref,
785 ) {
786 Ok(Some(value)) => {
787 debug!(flag = %key_str, ?value, "Flag evaluated locally");
788 return Ok(Some(value));
789 }
790 Ok(None) => {
791 if self.options.local_evaluation_only {
792 debug!(flag = %key_str, "Flag not found locally, skipping remote fallback");
793 return Ok(None);
794 }
795 debug!(flag = %key_str, "Flag not found locally, falling back to API");
796 }
797 Err(e) => {
798 if self.options.local_evaluation_only {
799 debug!(flag = %key_str, error = %e.message, "Inconclusive local evaluation, skipping remote fallback");
800 return Ok(None);
801 }
802 debug!(flag = %key_str, error = %e.message, "Inconclusive local evaluation, falling back to API");
803 }
804 }
805 }
806
807 trace!(flag = %key_str, "Fetching flag from API");
809 let (feature_flags, _payloads) = self
810 .get_feature_flags(distinct_id_str, groups, person_properties, group_properties)
811 .await?;
812 Ok(feature_flags.get(&key_str).cloned())
813 }
814
815 #[must_use = "feature flag enabled check result should be used"]
826 #[deprecated(
827 since = "0.6.0",
828 note = "Use Client::evaluate_flags() to fetch a snapshot, then call .is_enabled(key) \
829 on it. The snapshot deduplicates $feature_flag_called events and supports \
830 attaching rich metadata to captured events via Event::with_flags()."
831 )]
832 #[allow(deprecated)] pub async fn is_feature_enabled<K: Into<String>, D: Into<String>>(
834 &self,
835 key: K,
836 distinct_id: D,
837 groups: Option<HashMap<String, String>>,
838 person_properties: Option<HashMap<String, serde_json::Value>>,
839 group_properties: Option<HashMap<String, HashMap<String, serde_json::Value>>>,
840 ) -> Result<bool, Error> {
841 let flag_value = self
842 .get_feature_flag(
843 key.into(),
844 distinct_id.into(),
845 groups,
846 person_properties,
847 group_properties,
848 )
849 .await?;
850 Ok(match flag_value {
851 Some(FlagValue::Boolean(b)) => b,
852 Some(FlagValue::String(_)) => true, None => false,
854 })
855 }
856
857 #[must_use = "feature flag payload result should be used"]
874 #[deprecated(
875 since = "0.6.0",
876 note = "Use Client::evaluate_flags() to fetch a snapshot, then call \
877 .get_flag_payload(key) on it. Reading the payload from a snapshot is \
878 event-free, matching this method's behavior, and avoids the per-call \
879 /flags request."
880 )]
881 pub async fn get_feature_flag_payload<K: Into<String>, D: Into<String>>(
882 &self,
883 key: K,
884 distinct_id: D,
885 ) -> Result<Option<serde_json::Value>, Error> {
886 if self.options.is_disabled() {
887 trace!("Client is disabled, skipping feature flag payload request");
888 return Ok(None);
889 }
890
891 let key_str = key.into();
892 let flags_endpoint = self.options.endpoints().build_url(Endpoint::Flags);
893
894 let mut payload = json!({
895 "api_key": self.options.api_key,
896 "distinct_id": distinct_id.into(),
897 });
898
899 if self.options.disable_geoip {
901 payload["disable_geoip"] = json!(true);
902 }
903
904 let distinct_id = payload.get("distinct_id").and_then(|v| v.as_str());
905 let response = match self
906 .client
907 .post(&flags_endpoint)
908 .header(CONTENT_TYPE, "application/json")
909 .header(USER_AGENT, get_default_user_agent())
910 .json(&payload)
911 .timeout(Duration::from_secs(
912 self.options.feature_flags_request_timeout_seconds,
913 ))
914 .send()
915 .await
916 {
917 Ok(r) => r,
918 Err(e) => {
919 let err = Error::Connection(e.to_string());
920 report_flags_error(
921 &self.options.on_error,
922 &flags_endpoint,
923 distinct_id,
924 None,
925 None,
926 &err,
927 );
928 return Err(err);
929 }
930 };
931
932 if !response.status().is_success() {
933 return Ok(None);
934 }
935
936 let status = response.status().as_u16();
937 let flags_response: FeatureFlagsResponse = match response.json().await {
938 Ok(r) => r,
939 Err(e) => {
940 let err = Error::Serialization(format!("Failed to parse response: {e}"));
941 report_flags_error(
942 &self.options.on_error,
943 &flags_endpoint,
944 distinct_id,
945 Some(status),
946 None,
947 &err,
948 );
949 return Err(err);
950 }
951 };
952
953 let (_flags, payloads) = flags_response.normalize();
954 Ok(payloads.get(&key_str).cloned())
955 }
956
957 #[allow(clippy::too_many_arguments)]
977 pub fn evaluate_feature_flag_locally(
978 &self,
979 flag: &FeatureFlag,
980 distinct_id: &str,
981 person_properties: &HashMap<String, serde_json::Value>,
982 groups: &HashMap<String, String>,
983 group_properties: &HashMap<String, HashMap<String, serde_json::Value>>,
984 ) -> Result<FlagValue, Error> {
985 let group_type_mapping = self
986 .local_evaluator
987 .as_ref()
988 .map(|ev| ev.cache().get_group_type_mapping())
989 .unwrap_or_default();
990 match_feature_flag(
991 flag,
992 distinct_id,
993 person_properties,
994 groups,
995 group_properties,
996 &group_type_mapping,
997 )
998 .map_err(|e| Error::InconclusiveMatch(e.message))
999 }
1000
1001 pub async fn evaluate_flags<S: Into<String>>(
1024 &self,
1025 distinct_id: S,
1026 options: EvaluateFlagsOptions,
1027 ) -> Result<FeatureFlagEvaluations, Error> {
1028 let distinct_id: String = distinct_id.into();
1029 let host = self.flag_event_host();
1030
1031 if distinct_id.is_empty() || self.options.is_disabled() {
1032 return Ok(FeatureFlagEvaluations::empty(host));
1033 }
1034
1035 let mut options = options;
1036 options.groups.get_or_insert_with(HashMap::new);
1037 options.group_properties.get_or_insert_with(HashMap::new);
1038
1039 let mut records: HashMap<String, EvaluatedFlagRecord> = HashMap::new();
1040 let mut locally_evaluated_keys: HashSet<String> = HashSet::new();
1041
1042 if let Some(evaluator) = &self.local_evaluator {
1043 let mut person_props_owned = options.person_properties.clone().unwrap_or_default();
1044 person_props_owned
1045 .entry("distinct_id".to_string())
1046 .or_insert_with(|| json!(distinct_id.clone()));
1047 let groups_owned = options.groups.clone().unwrap_or_default();
1048 let group_props_owned = options.group_properties.clone().unwrap_or_default();
1049 let local_results = evaluator.evaluate_all_flags_with_details(
1050 &distinct_id,
1051 &person_props_owned,
1052 &groups_owned,
1053 &group_props_owned,
1054 );
1055 let local_minimal_gate = evaluator.cache().minimal_flag_called_events();
1059 for (key, result) in local_results {
1060 if let Some(filter) = &options.flag_keys {
1061 if !filter.iter().any(|k| k == &key) {
1062 continue;
1063 }
1064 }
1065 if let Ok(value) = result.result {
1066 records.insert(
1067 key.clone(),
1068 local_record(
1069 value,
1070 result.payload,
1071 result.has_experiment,
1072 local_minimal_gate,
1073 ),
1074 );
1075 locally_evaluated_keys.insert(key);
1076 }
1077 }
1078 }
1079
1080 let mut request_id: Option<String> = None;
1081 let mut errors_while_computing = false;
1082 let mut quota_limited = false;
1083
1084 let local_covers_request = options
1089 .flag_keys
1090 .as_ref()
1091 .is_some_and(|keys| keys.iter().all(|k| locally_evaluated_keys.contains(k)));
1092
1093 if !options.only_evaluate_locally && !local_covers_request {
1094 match self.fetch_flag_details(&distinct_id, &options).await {
1099 Ok(response) => {
1100 request_id = response.request_id;
1101 errors_while_computing = response.errors_while_computing_flags;
1102 quota_limited = response.quota_limited;
1103 let remote_minimal_gate = response.minimal_flag_called_events;
1106 for (key, detail) in response.flags {
1107 if locally_evaluated_keys.contains(&key) {
1108 continue;
1109 }
1110 records.insert(key, remote_record_from_detail(detail, remote_minimal_gate));
1111 }
1112 }
1113 Err(e) => {
1114 if records.is_empty() {
1115 return Err(e);
1116 }
1117 debug!(
1118 error = e.to_string(),
1119 local_count = records.len(),
1120 "/flags fetch failed; returning snapshot from local results only"
1121 );
1122 errors_while_computing = true;
1123 }
1124 }
1125 }
1126
1127 Ok(FeatureFlagEvaluations::new(
1128 host,
1129 distinct_id,
1130 records,
1131 options.groups.unwrap_or_default(),
1132 options.disable_geoip,
1133 request_id,
1134 None,
1135 errors_while_computing,
1136 quota_limited,
1137 ))
1138 }
1139
1140 fn flag_event_host(&self) -> Arc<dyn FeatureFlagEvaluationsHost> {
1141 self.flag_event_host
1142 .get_or_init(|| {
1143 Arc::new(AsyncFlagEventHost::from_options(
1144 &self.options,
1145 self.transport.clone(),
1146 )) as Arc<dyn FeatureFlagEvaluationsHost>
1147 })
1148 .clone()
1149 }
1150
1151 async fn send_feature_flags_request(
1152 &self,
1153 flags_endpoint: &str,
1154 payload: &serde_json::Value,
1155 ) -> Result<reqwest::Response, Error> {
1156 let mut attempt = 1;
1157 loop {
1158 let request = self
1159 .client
1160 .post(flags_endpoint)
1161 .header(CONTENT_TYPE, "application/json")
1162 .header(USER_AGENT, get_default_user_agent())
1163 .json(payload)
1164 .timeout(Duration::from_secs(
1165 self.options.feature_flags_request_timeout_seconds,
1166 ));
1167 #[cfg(feature = "test-harness")]
1168 let request = {
1169 let mut request = request;
1170 if let Some(ref extra) = self.options.extra_capture_headers {
1171 for (k, v) in extra {
1172 request = request.header(k.as_str(), v.as_str());
1173 }
1174 }
1175 request
1176 };
1177 let result = request.send().await;
1178
1179 match result {
1180 Ok(response) => match super::retry::feature_flags_after_response(
1181 &self.options,
1182 attempt,
1183 response.status().as_u16(),
1184 ) {
1185 super::retry::FeatureFlagsResponseStep::Backoff(delay) => {
1186 tokio::time::sleep(delay).await;
1187 attempt += 1;
1188 }
1189 super::retry::FeatureFlagsResponseStep::Done => return Ok(response),
1190 },
1191 Err(e) => {
1192 let err_msg = e.to_string();
1193 match super::retry::feature_flags_after_transport_error(
1194 &self.options,
1195 attempt,
1196 is_retryable_feature_flags_error(&e),
1197 err_msg,
1198 ) {
1199 super::retry::FeatureFlagsTransportStep::Backoff(delay) => {
1200 tokio::time::sleep(delay).await;
1201 attempt += 1;
1202 }
1203 super::retry::FeatureFlagsTransportStep::Fail(err) => {
1204 report_flags_error(
1205 &self.options.on_error,
1206 flags_endpoint,
1207 payload.get("distinct_id").and_then(|v| v.as_str()),
1208 None,
1209 None,
1210 &err,
1211 );
1212 return Err(err);
1213 }
1214 }
1215 }
1216 }
1217 }
1218 }
1219
1220 async fn fetch_flag_details(
1221 &self,
1222 distinct_id: &str,
1223 options: &EvaluateFlagsOptions,
1224 ) -> Result<DetailedFlagsResponse, Error> {
1225 let flags_endpoint = self.options.endpoints().build_url(Endpoint::Flags);
1226
1227 let person_properties = options.person_properties.clone().unwrap_or_default();
1228 let groups = options.groups.clone().unwrap_or_default();
1229 let group_properties = options.group_properties.clone().unwrap_or_default();
1230 let effective_disable_geoip = options.disable_geoip.unwrap_or(self.options.disable_geoip);
1231
1232 let mut payload = json!({
1233 "api_key": self.options.api_key,
1234 "distinct_id": distinct_id,
1235 "groups": groups,
1236 "person_properties": person_properties,
1237 "group_properties": group_properties,
1238 "geoip_disable": effective_disable_geoip,
1239 });
1240 if let Some(flag_keys) = &options.flag_keys {
1241 payload["flag_keys_to_evaluate"] = json!(flag_keys);
1242 }
1243
1244 let response = self
1245 .send_feature_flags_request(&flags_endpoint, &payload)
1246 .await?;
1247
1248 if !response.status().is_success() {
1249 let status = response.status();
1250 let text = response
1251 .text()
1252 .await
1253 .unwrap_or_else(|_| "Unknown error".to_string());
1254 let err = Error::Connection(format!("API request failed with status {status}: {text}"));
1255 report_flags_error(
1256 &self.options.on_error,
1257 &flags_endpoint,
1258 Some(distinct_id),
1259 Some(status.as_u16()),
1260 Some(&text),
1261 &err,
1262 );
1263 return Err(err);
1264 }
1265
1266 let status = response.status().as_u16();
1267 let parsed = match response.json::<FeatureFlagsResponse>().await {
1268 Ok(p) => p,
1269 Err(e) => {
1270 let err =
1271 Error::Serialization(format!("Failed to parse feature flags response: {e}"));
1272 report_flags_error(
1273 &self.options.on_error,
1274 &flags_endpoint,
1275 Some(distinct_id),
1276 Some(status),
1277 None,
1278 &err,
1279 );
1280 return Err(err);
1281 }
1282 };
1283 Ok(extract_flag_details(parsed))
1284 }
1285}
1286
1287impl Drop for Client {
1288 fn drop(&mut self) {
1294 let Some(transport) = &self.transport else {
1295 return;
1296 };
1297 if transport.begin_close() {
1298 let (tx, rx) = std::sync::mpsc::channel();
1299 if transport.send_control(Control::Shutdown(Completion::Blocking(tx))) {
1300 let _ = rx.recv();
1301 }
1302 }
1303 transport.join();
1308 }
1309}
1310
1311#[cfg(test)]
1312mod minimal_gate_tests {
1313 use super::*;
1314 use crate::client::minimal_gate_test_support::{definitions, RecordingHost};
1315
1316 fn test_client(cache: FlagCache, host: Arc<dyn FeatureFlagEvaluationsHost>) -> Client {
1317 let options = ClientOptions::from(("phc_test", "http://localhost:0"));
1318 let client = Client {
1319 options,
1320 client: HttpClient::builder().build().unwrap(),
1321 local_evaluator: Some(LocalEvaluator::new(cache)),
1322 _flag_poller: None,
1323 flag_event_host: OnceLock::new(),
1324 transport: None,
1325 };
1326 client
1327 .flag_event_host
1328 .set(host)
1329 .unwrap_or_else(|_| panic!("host already set"));
1330 client
1331 }
1332
1333 async fn evaluate(client: &Client) -> FeatureFlagEvaluations {
1334 client
1335 .evaluate_flags(
1336 "user-1",
1337 EvaluateFlagsOptions {
1338 only_evaluate_locally: true,
1339 ..Default::default()
1340 },
1341 )
1342 .await
1343 .expect("local evaluate_flags")
1344 }
1345
1346 #[tokio::test]
1351 async fn local_gate_pinned_at_evaluation_survives_cache_mutation_to_off() {
1352 let cache = FlagCache::new();
1353 cache.update(definitions(Some(false), true)); let host = Arc::new(RecordingHost::default());
1355 let client = test_client(cache.clone(), Arc::clone(&host) as _);
1356
1357 let snapshot = evaluate(&client).await;
1358 cache.update(definitions(Some(false), false));
1360
1361 assert!(snapshot.is_enabled("gated"));
1362 let captured = host.captured.lock().unwrap();
1363 assert_eq!(captured.len(), 1);
1364 assert!(
1365 captured[0].minimal,
1366 "event must reflect the gate pinned at evaluation (on), not the mutated cache (off)"
1367 );
1368 }
1369
1370 #[tokio::test]
1371 async fn local_gate_pinned_at_evaluation_survives_cache_mutation_to_on() {
1372 let cache = FlagCache::new();
1373 cache.update(definitions(Some(false), false)); let host = Arc::new(RecordingHost::default());
1375 let client = test_client(cache.clone(), Arc::clone(&host) as _);
1376
1377 let snapshot = evaluate(&client).await;
1378 cache.update(definitions(Some(false), true));
1380
1381 assert!(snapshot.is_enabled("gated"));
1382 let captured = host.captured.lock().unwrap();
1383 assert_eq!(captured.len(), 1);
1384 assert!(
1385 !captured[0].minimal,
1386 "event must reflect the gate pinned at evaluation (off), not the mutated cache (on)"
1387 );
1388 }
1389
1390 #[tokio::test]
1391 async fn local_has_experiment_is_threaded_from_definitions() {
1392 let cache = FlagCache::new();
1393 cache.update(definitions(Some(false), true));
1394 let host = Arc::new(RecordingHost::default());
1395 let client = test_client(cache, Arc::clone(&host) as _);
1396
1397 assert!(evaluate(&client).await.is_enabled("gated"));
1398 let captured = host.captured.lock().unwrap();
1399 assert_eq!(
1400 captured[0].properties.get("$feature_flag_has_experiment"),
1401 Some(&serde_json::json!(false))
1402 );
1403 assert!(captured[0].minimal);
1404 }
1405}
1406
1407#[cfg(test)]
1408mod local_payload_tests {
1409 use super::*;
1410 use crate::client::local_payload_test_support::payload_definitions;
1411 use crate::client::minimal_gate_test_support::RecordingHost;
1412 use serde_json::json;
1413
1414 async fn snapshot() -> FeatureFlagEvaluations {
1415 let cache = FlagCache::new();
1416 cache.update(payload_definitions());
1417 let options = ClientOptions::from(("phc_test", "http://localhost:0"));
1418 let client = Client {
1419 options,
1420 client: HttpClient::builder().build().unwrap(),
1421 local_evaluator: Some(LocalEvaluator::new(cache)),
1422 _flag_poller: None,
1423 flag_event_host: OnceLock::new(),
1424 transport: None,
1425 };
1426 client
1427 .flag_event_host
1428 .set(Arc::new(RecordingHost::default()) as _)
1429 .unwrap_or_else(|_| panic!("host already set"));
1430 client
1431 .evaluate_flags(
1432 "user-1",
1433 EvaluateFlagsOptions {
1434 only_evaluate_locally: true,
1435 ..Default::default()
1436 },
1437 )
1438 .await
1439 .expect("local evaluate_flags")
1440 }
1441
1442 #[tokio::test]
1447 async fn local_evaluation_surfaces_payloads_matching_the_remote_shape() {
1448 let snapshot = snapshot().await;
1449
1450 assert_eq!(
1451 snapshot.get_flag_payload("json-string-payload"),
1452 Some(json!({"color": "blue"}))
1453 );
1454 assert_eq!(
1455 snapshot.get_flag_payload("parsed-payload"),
1456 Some(json!({"color": "blue"}))
1457 );
1458 assert_eq!(
1459 snapshot.get_flag_payload("quoted-string-payload"),
1460 Some(json!("just text"))
1461 );
1462 assert_eq!(
1463 snapshot.get_flag_payload("undecodable-payload"),
1464 Some(json!("not json"))
1465 );
1466 }
1467
1468 #[tokio::test]
1469 async fn local_payload_is_keyed_by_the_matched_variant() {
1470 let snapshot = snapshot().await;
1471
1472 assert_eq!(
1473 snapshot.get_flag("variant-payload"),
1474 Some(FlagValue::String("test".to_string()))
1475 );
1476 assert_eq!(
1477 snapshot.get_flag_payload("variant-payload"),
1478 Some(json!({"tier": 2}))
1479 );
1480 }
1481
1482 #[tokio::test]
1483 async fn local_payload_is_absent_without_a_matching_payload() {
1484 let snapshot = snapshot().await;
1485
1486 assert_eq!(snapshot.get_flag_payload("no-payload"), None);
1487 assert_eq!(snapshot.get_flag_payload("not-a-flag"), None);
1488
1489 assert_eq!(
1493 snapshot.get_flag("disabled-with-payload"),
1494 Some(FlagValue::Boolean(false))
1495 );
1496 assert_eq!(snapshot.get_flag_payload("disabled-with-payload"), None);
1497 }
1498}