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, 403, 413, 405, 429, 501, ];
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 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 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 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 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
500fn 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}