Skip to main content

statsig_rust/networking/
network_client.rs

1use chrono::Utc;
2
3use super::network_error::NetworkError;
4use super::providers::get_network_provider;
5use super::{HttpMethod, NetworkProvider, RequestArgs, Response};
6use crate::networking::proxy_config::ProxyConfig;
7use crate::observability::observability_client_adapter::{MetricType, ObservabilityEvent};
8use crate::observability::ops_stats::{OpsStatsForInstance, OPS_STATS};
9use crate::observability::ErrorBoundaryEvent;
10use crate::sdk_diagnostics::marker::{ActionType, Marker, StepType};
11use crate::utils::{
12    get_loggable_sdk_key, is_version_segment, split_host_and_path, strip_query_and_fragment,
13};
14use crate::{log_d, log_i, log_w, StatsigOptions};
15use std::collections::HashMap;
16use std::sync::atomic::{AtomicBool, Ordering};
17use std::sync::{Arc, Weak};
18use std::time::{Duration, Instant};
19
20const NON_RETRY_CODES: [u16; 6] = [
21    400, // Bad Request
22    403, // Forbidden
23    413, // Payload Too Large
24    405, // Method Not Allowed
25    429, // Too Many Requests
26    501, // Not Implemented
27];
28const SHUTDOWN_ERROR: &str = "Request was aborted because the client is shutting down";
29
30const MAX_REQUEST_PATH_LENGTH: usize = 64;
31const DOWNLOAD_CONFIG_SPECS_ENDPOINT: &str = "download_config_specs";
32const GET_ID_LISTS_ENDPOINT: &str = "get_id_lists";
33const DOWNLOAD_ID_LIST_FILE_ENDPOINT: &str = "download_id_list_file";
34const LOG_EVENT_ENDPOINT: &str = "log_event";
35const NETWORK_REQUEST_LATENCY_METRIC: &str = "network_request.latency";
36const REQUEST_PATH_TAG: &str = "request_path";
37const STATUS_CODE_TAG: &str = "status_code";
38const IS_SUCCESS_TAG: &str = "is_success";
39const SDK_KEY_TAG: &str = "sdk_key";
40const SOURCE_SERVICE_TAG: &str = "source_service";
41const ID_LIST_FILE_ID_TAG: &str = "id_list_file_id";
42const DELTAS_USED_TAG: &str = "deltas_used";
43
44const TAG: &str = stringify!(NetworkClient);
45
46pub struct NetworkClient {
47    headers: HashMap<String, String>,
48    is_shutdown: Arc<AtomicBool>,
49    ops_stats: Arc<OpsStatsForInstance>,
50    net_provider: Weak<dyn NetworkProvider>,
51    disable_network: bool,
52    proxy_config: Option<ProxyConfig>,
53    ca_cert_pem: Option<Vec<u8>>,
54    silent_on_network_failure: bool,
55    disable_file_streaming: bool,
56    log_event_connection_reuse: bool,
57    loggable_sdk_key: String,
58}
59
60impl NetworkClient {
61    #[must_use]
62    pub fn new(
63        sdk_key: &str,
64        headers: Option<HashMap<String, String>>,
65        options: Option<&StatsigOptions>,
66    ) -> Self {
67        let net_provider = get_network_provider();
68        let (disable_network, proxy_config, ca_cert_pem, log_event_connection_reuse) = options
69            .map(|opts| {
70                let ca_cert_pem = opts
71                    .proxy_config
72                    .as_ref()
73                    .and_then(|cfg| cfg.ca_cert_path.as_ref())
74                    .and_then(|path| {
75                        if path.is_empty() {
76                            return None;
77                        }
78                        match std::fs::read(path) {
79                            Ok(bytes) => Some(bytes),
80                            Err(e) => {
81                                log_w!(
82                                    TAG,
83                                    "Failed to read proxy_config.ca_cert_path '{}': {}",
84                                    path,
85                                    e
86                                );
87                                None
88                            }
89                        }
90                    });
91                (
92                    opts.disable_network.unwrap_or(false),
93                    opts.proxy_config.clone(),
94                    ca_cert_pem,
95                    opts.log_event_connection_reuse.unwrap_or(true),
96                )
97            })
98            .unwrap_or((false, None, None, true));
99
100        let sdk_instance_id = options
101            .map(|opts| opts.get_sdk_instance_id(sdk_key))
102            .unwrap_or(sdk_key);
103
104        NetworkClient {
105            headers: headers.unwrap_or_default(),
106            is_shutdown: Arc::new(AtomicBool::new(false)),
107            net_provider,
108            ops_stats: OPS_STATS.get_for_instance(sdk_instance_id),
109            disable_network,
110            proxy_config,
111            ca_cert_pem,
112            silent_on_network_failure: false,
113            disable_file_streaming: options
114                .map(|opts| opts.disable_disk_access.unwrap_or(false))
115                .unwrap_or(false),
116            log_event_connection_reuse,
117            loggable_sdk_key: get_loggable_sdk_key(sdk_key),
118        }
119    }
120
121    pub fn shutdown(&self) {
122        self.is_shutdown.store(true, Ordering::SeqCst);
123    }
124
125    pub async fn get(&self, request_args: RequestArgs) -> Result<Response, NetworkError> {
126        self.make_request(HttpMethod::GET, request_args).await
127    }
128
129    pub async fn post(
130        &self,
131        mut request_args: RequestArgs,
132        body: Option<Vec<u8>>,
133    ) -> Result<Response, NetworkError> {
134        request_args.body = body;
135        self.make_request(HttpMethod::POST, request_args).await
136    }
137
138    async fn make_request(
139        &self,
140        method: HttpMethod,
141        mut request_args: RequestArgs,
142    ) -> Result<Response, NetworkError> {
143        let is_shutdown = if let Some(is_shutdown) = &request_args.is_shutdown {
144            is_shutdown.clone()
145        } else {
146            self.is_shutdown.clone()
147        };
148
149        if self.disable_network {
150            log_d!(TAG, "Network is disabled, not making requests");
151            return Err(NetworkError::DisableNetworkOn(request_args.url));
152        }
153
154        request_args.populate_headers(self.headers.clone());
155
156        if request_args.disable_file_streaming.is_none() {
157            request_args.disable_file_streaming = Some(self.disable_file_streaming);
158        }
159
160        if request_args.ca_cert_pem.is_none() {
161            request_args.ca_cert_pem = self.ca_cert_pem.clone();
162        }
163
164        if self.log_event_connection_reuse && !request_args.log_event_connection_reuse {
165            request_args.log_event_connection_reuse = true;
166        }
167
168        let mut merged_headers = request_args.headers.unwrap_or_default();
169        if !self.headers.is_empty() {
170            merged_headers.extend(self.headers.clone());
171        }
172        merged_headers.insert(
173            "STATSIG-CLIENT-TIME".into(),
174            Utc::now().timestamp_millis().to_string(),
175        );
176        request_args.headers = Some(merged_headers);
177
178        // passing down proxy config through request args
179        if let Some(proxy_config) = &self.proxy_config {
180            request_args.proxy_config = Some(proxy_config.clone());
181        }
182        let mut attempt = 0;
183
184        loop {
185            if let Some(key) = request_args.diagnostics_key {
186                self.ops_stats.add_marker(
187                    Marker::new(key, ActionType::Start, Some(StepType::NetworkRequest))
188                        .with_attempt(attempt)
189                        .with_url(request_args.url.clone()),
190                    None,
191                );
192            }
193            if is_shutdown.load(Ordering::SeqCst) {
194                log_i!(TAG, "{}", SHUTDOWN_ERROR);
195                return Err(NetworkError::ShutdownError(request_args.url));
196            }
197
198            let request_start = Instant::now();
199            let mut response = match self.net_provider.upgrade() {
200                Some(net_provider) => net_provider.send(&method, &request_args).await,
201                None => {
202                    return Err(NetworkError::RequestFailed(
203                        request_args.url,
204                        None,
205                        "Failed to get a NetworkProvider instance".to_string(),
206                    ));
207                }
208            };
209
210            let status = response.status_code;
211            let error_message = response
212                .error
213                .clone()
214                .unwrap_or_else(|| get_error_message_for_status(status, response.data.as_mut()));
215
216            let content_type = response
217                .data
218                .as_ref()
219                .and_then(|data| data.get_header_ref("content-type"));
220
221            log_d!(
222                TAG,
223                "Response url({}) status({:?}) content-type({:?})",
224                &request_args.url,
225                response.status_code,
226                content_type
227            );
228
229            let sdk_region_str = response
230                .data
231                .as_ref()
232                .and_then(|data| data.get_header_ref("x-statsig-region").cloned());
233            let success = (200..300).contains(&status.unwrap_or(0));
234            let duration_ms = request_start.elapsed().as_millis() as f64;
235            self.log_network_request_latency_to_ob(&request_args, status, success, duration_ms);
236
237            if let Some(key) = request_args.diagnostics_key {
238                let mut end_marker =
239                    Marker::new(key, ActionType::End, Some(StepType::NetworkRequest))
240                        .with_attempt(attempt)
241                        .with_url(request_args.url.clone())
242                        .with_is_success(success)
243                        .with_content_type(content_type.cloned())
244                        .with_sdk_region(sdk_region_str.map(|s| s.to_owned()));
245
246                if let Some(status_code) = status {
247                    end_marker = end_marker.with_status_code(status_code);
248                }
249
250                let error_map = if !error_message.is_empty() {
251                    let mut map = HashMap::new();
252                    map.insert("name".to_string(), "NetworkError".to_string());
253                    map.insert("message".to_string(), error_message.clone());
254                    let status_string = match status {
255                        Some(code) => code.to_string(),
256                        None => "None".to_string(),
257                    };
258                    map.insert("code".to_string(), status_string);
259                    Some(map)
260                } else {
261                    None
262                };
263
264                if let Some(error_map) = error_map {
265                    end_marker = end_marker.with_error(error_map);
266                }
267
268                self.ops_stats.add_marker(end_marker, None);
269            }
270
271            if success {
272                return Ok(response);
273            }
274
275            if NON_RETRY_CODES.contains(&status.unwrap_or(0)) {
276                let error = NetworkError::RequestNotRetryable(
277                    request_args.url.clone(),
278                    status,
279                    error_message,
280                );
281                self.log_warning(&error, &request_args);
282                return Err(error);
283            }
284
285            if attempt >= request_args.retries {
286                let error = NetworkError::RetriesExhausted(
287                    request_args.url.clone(),
288                    status,
289                    attempt + 1,
290                    error_message,
291                );
292                self.log_warning(&error, &request_args);
293                return Err(error);
294            }
295
296            attempt += 1;
297            let backoff_ms = 2_u64.pow(attempt) * 100;
298
299            log_i!(
300                TAG, "Network request failed with status code {} (attempt {}/{}), will retry after {}ms...\n{}",
301                status.map_or("unknown".to_string(), |s| s.to_string()),
302                attempt,
303                request_args.retries + 1,
304                backoff_ms,
305                error_message
306            );
307
308            tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
309        }
310    }
311
312    pub fn mute_network_error_log(mut self) -> Self {
313        self.silent_on_network_failure = true;
314        self
315    }
316
317    // Logging helpers
318    fn log_warning(&self, error: &NetworkError, args: &RequestArgs) {
319        let exception = error.name();
320
321        log_w!(TAG, "{}", error);
322        if !self.silent_on_network_failure {
323            let dedupe_key = format!("{:?}", args.diagnostics_key);
324            self.ops_stats.log_error(ErrorBoundaryEvent {
325                tag: TAG.to_string(),
326                exception: exception.to_string(),
327                bypass_dedupe: false,
328                info: serde_json::to_string(error).unwrap_or_default(),
329                dedupe_key: Some(dedupe_key),
330                extra: Some(get_network_error_extra_tags(error, args)),
331            });
332        }
333    }
334
335    // ------------------------------------------------------------
336    // Observability Logging Helpers (OB only) - START
337    // ------------------------------------------------------------
338    fn log_network_request_latency_to_ob(
339        &self,
340        request_args: &RequestArgs,
341        status: Option<u16>,
342        success: bool,
343        duration_ms: f64,
344    ) {
345        let url = request_args.url.as_str();
346        if !should_log_network_request_latency(url) {
347            return;
348        }
349
350        let status_code = status
351            .map(|code| code.to_string())
352            .unwrap_or("none".to_string());
353        let tags = get_network_request_latency_tags(
354            request_args,
355            status_code,
356            success,
357            self.loggable_sdk_key.clone(),
358        );
359
360        self.ops_stats.log(ObservabilityEvent::new_event(
361            MetricType::Dist,
362            NETWORK_REQUEST_LATENCY_METRIC.to_string(),
363            duration_ms,
364            Some(tags),
365        ));
366    }
367}
368
369fn get_network_error_extra_tags(
370    error: &NetworkError,
371    request_args: &RequestArgs,
372) -> HashMap<String, String> {
373    let (_, request_path) = get_source_service_and_request_path(&request_args.url);
374    HashMap::from([
375        (REQUEST_PATH_TAG.to_string(), request_path),
376        (
377            STATUS_CODE_TAG.to_string(),
378            error
379                .status_code()
380                .map(|code| code.to_string())
381                .unwrap_or("none".to_string()),
382        ),
383    ])
384}
385
386fn get_network_request_latency_tags(
387    request_args: &RequestArgs,
388    status_code: String,
389    success: bool,
390    loggable_sdk_key: String,
391) -> HashMap<String, String> {
392    let (source_service, request_path) = get_source_service_and_request_path(&request_args.url);
393    let mut tags = HashMap::from([
394        (REQUEST_PATH_TAG.to_string(), request_path),
395        (STATUS_CODE_TAG.to_string(), status_code),
396        (IS_SUCCESS_TAG.to_string(), success.to_string()),
397        (SDK_KEY_TAG.to_string(), loggable_sdk_key),
398        (SOURCE_SERVICE_TAG.to_string(), source_service),
399        (
400            DELTAS_USED_TAG.to_string(),
401            request_args.deltas_enabled.to_string(),
402        ),
403    ]);
404    if let Some(id_list_file_id) = request_args
405        .id_list_file_id
406        .as_ref()
407        .filter(|id| !id.is_empty())
408    {
409        tags.insert(ID_LIST_FILE_ID_TAG.to_string(), id_list_file_id.clone());
410    }
411
412    tags
413}
414
415fn is_latency_loggable_endpoint(endpoint: &str) -> bool {
416    endpoint == DOWNLOAD_CONFIG_SPECS_ENDPOINT
417        || endpoint == GET_ID_LISTS_ENDPOINT
418        || endpoint == DOWNLOAD_ID_LIST_FILE_ENDPOINT
419        || endpoint == LOG_EVENT_ENDPOINT
420}
421
422fn get_version_and_endpoint_for_latency<'a>(
423    segments: &'a [&'a str],
424) -> Option<(usize, &'a str, &'a str)> {
425    // Find a known endpoint pattern, then verify the segment right before it is `/v{number}`.
426    segments
427        .iter()
428        .enumerate()
429        .find_map(|(endpoint_index, endpoint_segment)| {
430            if !is_latency_loggable_endpoint(endpoint_segment) || endpoint_index == 0 {
431                return None;
432            }
433
434            let version_index = endpoint_index - 1;
435            let version_segment = segments[version_index];
436            is_version_segment(version_segment).then_some((
437                version_index,
438                version_segment,
439                *endpoint_segment,
440            ))
441        })
442}
443
444fn should_log_network_request_latency(url: &str) -> bool {
445    let (_, raw_path) = split_host_and_path(url);
446    let normalized_path = strip_query_and_fragment(raw_path).trim_start_matches('/');
447    let segments: Vec<&str> = normalized_path
448        .split('/')
449        .filter(|segment| !segment.is_empty())
450        .collect();
451
452    get_version_and_endpoint_for_latency(&segments).is_some()
453}
454
455fn with_host_prefix(host_prefix: &str, path: &str) -> String {
456    if host_prefix.is_empty() {
457        path.to_string()
458    } else {
459        format!("{host_prefix}{path}")
460    }
461}
462
463fn get_source_service_and_request_path(url: &str) -> (String, String) {
464    let (host_prefix, raw_path) = split_host_and_path(url);
465    let normalized_path = strip_query_and_fragment(raw_path).trim_start_matches('/');
466    let segments: Vec<&str> = normalized_path
467        .split('/')
468        .filter(|segment| !segment.is_empty())
469        .collect();
470
471    if let Some((version_index, version_segment, endpoint_segment)) =
472        get_version_and_endpoint_for_latency(&segments)
473    {
474        let request_path = format!("/{version_segment}/{endpoint_segment}");
475        let source_service_suffix = segments[..version_index].join("/");
476        let source_service = with_host_prefix(&host_prefix, &source_service_suffix)
477            .trim_end_matches('/')
478            .to_string();
479        return (source_service, request_path);
480    }
481
482    let fallback_request_path: String = normalized_path
483        .chars()
484        .take(MAX_REQUEST_PATH_LENGTH)
485        .collect();
486    let request_path = if fallback_request_path.is_empty() {
487        "/".to_string()
488    } else {
489        format!("/{fallback_request_path}")
490    };
491    let source_service = host_prefix.trim_end_matches('/').to_string();
492    (source_service, request_path)
493}
494
495#[cfg(test)]
496fn get_request_path(url: &str) -> String {
497    get_source_service_and_request_path(url).1
498}
499
500// ------------------------------------------------------------
501// Observability Logging Helpers (OB only) - END
502// ------------------------------------------------------------
503
504fn get_error_message_for_status(
505    status: Option<u16>,
506    data: Option<&mut super::ResponseData>,
507) -> String {
508    if (200..300).contains(&status.unwrap_or(0)) {
509        return String::new();
510    }
511
512    let mut message = String::new();
513    if let Some(data) = data {
514        let lossy_str = data.read_to_string().unwrap_or_default();
515        if lossy_str.is_ascii() {
516            message = lossy_str.to_string();
517        }
518    }
519
520    let status_value = match status {
521        Some(code) => code,
522        None => return format!("HTTP Error None: {message}"),
523    };
524
525    let generic_message = match status_value {
526        400 => "Bad Request",
527        401 => "Unauthorized",
528        403 => "Forbidden",
529        404 => "Not Found",
530        405 => "Method Not Allowed",
531        406 => "Not Acceptable",
532        408 => "Request Timeout",
533        500 => "Internal Server Error",
534        502 => "Bad Gateway",
535        503 => "Service Unavailable",
536        504 => "Gateway Timeout",
537        0 => "Unknown Error",
538        _ => return format!("HTTP Error {status_value}: {message}"),
539    };
540
541    if message.is_empty() {
542        return generic_message.to_string();
543    }
544
545    format!("{generic_message}: {message}")
546}
547
548#[cfg(test)]
549mod tests {
550    use super::{
551        get_network_error_extra_tags, get_network_request_latency_tags, get_request_path,
552        get_source_service_and_request_path, should_log_network_request_latency, NetworkClient,
553        DELTAS_USED_TAG, ID_LIST_FILE_ID_TAG, REQUEST_PATH_TAG, STATUS_CODE_TAG,
554    };
555    use crate::networking::{NetworkError, RequestArgs};
556    use crate::StatsigOptions;
557
558    #[test]
559    fn test_log_event_connection_reuse_defaults_to_true() {
560        assert!(NetworkClient::new("secret-test", None, None).log_event_connection_reuse);
561        assert!(
562            NetworkClient::new("secret-test", None, Some(&StatsigOptions::default()))
563                .log_event_connection_reuse
564        );
565
566        let options = StatsigOptions {
567            log_event_connection_reuse: Some(false),
568            ..StatsigOptions::default()
569        };
570        assert!(
571            !NetworkClient::new("secret-test", None, Some(&options)).log_event_connection_reuse
572        );
573    }
574
575    #[test]
576    fn test_get_request_path_with_sample_urls() {
577        assert_eq!(
578            get_request_path(
579                "https://statsigcdn.openai.com/v1/download_id_list_file/3wHgh0FhoQH0p"
580            ),
581            "/v1/download_id_list_file"
582        );
583        assert_eq!(
584            get_request_path("https://statsigcdn.openai.com/v1/download_id_list_file/Q9mXcz7L1P43tRb8kV2dHyw%2FM6nJf0Ae5uTqsrC4Gp9KZ?foo=bar"),
585            "/v1/download_id_list_file"
586        );
587        assert_eq!(
588            get_request_path("https://api.oaistatsig.com/v1/get_id_lists/secret-abcdef"),
589            "/v1/get_id_lists"
590        );
591        assert_eq!(
592            get_request_path(
593                "https://statsigcdn.openai.com/v2/download_config_specs/secret-123456"
594            ),
595            "/v2/download_config_specs"
596        );
597        assert_eq!(
598            get_request_path("https://api.oaistatsig.com/v1/log_event"),
599            "/v1/log_event"
600        );
601    }
602
603    #[test]
604    fn test_should_log_network_request_latency_for_supported_endpoints() {
605        assert!(should_log_network_request_latency(
606            "https://api.oaistatsig.com/v1/log_event"
607        ));
608        assert!(!should_log_network_request_latency(
609            "https://api.oaistatsig.com/v1/sdk_exception"
610        ));
611        assert!(should_log_network_request_latency(
612            "https://api.oaistatsig.com/v1/get_id_lists/secret-abcdef"
613        ));
614        assert!(should_log_network_request_latency(
615            "https://statsigcdn.openai.com/v2/download_config_specs/secret-123456"
616        ));
617        assert!(should_log_network_request_latency(
618            "https://statsigcdn.openai.com/v1/download_id_list_file/3wHgh0FhoQH0p"
619        ));
620    }
621
622    #[test]
623    fn test_get_source_service_and_request_path() {
624        let (source_service, request_path) = get_source_service_and_request_path(
625            "http://127.0.0.1:12345/mock-uuid/v2/download_config_specs/secret-key.json?x=1",
626        );
627        assert_eq!(source_service, "http://127.0.0.1:12345/mock-uuid");
628        assert_eq!(request_path, "/v2/download_config_specs");
629    }
630
631    #[test]
632    fn test_network_latency_tags_include_id_list_file_id_only_when_present() {
633        let mut request_args = RequestArgs {
634            url: "https://statsigcdn.openai.com/v1/download_id_list_file/file-123".to_string(),
635            id_list_file_id: Some("file-123".to_string()),
636            ..RequestArgs::new()
637        };
638
639        let tags = get_network_request_latency_tags(
640            &request_args,
641            "200".to_string(),
642            true,
643            "client-key-123".to_string(),
644        );
645        assert_eq!(tags.get(ID_LIST_FILE_ID_TAG), Some(&"file-123".to_string()));
646        assert_eq!(tags.get(DELTAS_USED_TAG), Some(&"false".to_string()));
647        assert_eq!(
648            tags.get(REQUEST_PATH_TAG),
649            Some(&"/v1/download_id_list_file".to_string())
650        );
651
652        request_args.id_list_file_id = Some(String::new());
653        request_args.deltas_enabled = true;
654        let tags_without_id = get_network_request_latency_tags(
655            &request_args,
656            "200".to_string(),
657            true,
658            "client-key-123".to_string(),
659        );
660        assert!(!tags_without_id.contains_key(ID_LIST_FILE_ID_TAG));
661        assert_eq!(
662            tags_without_id.get(DELTAS_USED_TAG),
663            Some(&"true".to_string())
664        );
665    }
666
667    #[test]
668    fn test_network_error_extra_tags_include_request_path_and_status_code() {
669        let request_args = RequestArgs {
670            url: "https://prodregistryv2.org/v1/log_event".to_string(),
671            ..RequestArgs::new()
672        };
673        let error = NetworkError::RequestNotRetryable(
674            request_args.url.clone(),
675            Some(429),
676            "Too Many Requests".to_string(),
677        );
678
679        let tags = get_network_error_extra_tags(&error, &request_args);
680
681        assert_eq!(
682            tags.get(REQUEST_PATH_TAG),
683            Some(&"/v1/log_event".to_string())
684        );
685        assert_eq!(tags.get(STATUS_CODE_TAG), Some(&"429".to_string()));
686    }
687}