1use std::time::Duration;
2
3use backon::{BackoffBuilder, ExponentialBuilder};
4use reqwest::{Client, RequestBuilder, Response, StatusCode};
5use tokio::time::timeout;
6use tracing::{debug, instrument};
7
8use crate::{
9 api::OdosApiErrorResponse,
10 api_key::ApiKey,
11 error::{OdosError, Result},
12 error_code::OdosErrorCode,
13};
14
15#[derive(Debug, Clone)]
45pub struct RetryConfig {
46 pub max_retries: u32,
48
49 pub initial_backoff_ms: u64,
51
52 pub retry_server_errors: bool,
54
55 pub retry_predicate: Option<fn(&OdosError) -> bool>,
60}
61
62impl Default for RetryConfig {
63 fn default() -> Self {
64 Self {
65 max_retries: 3,
66 initial_backoff_ms: 100,
67 retry_server_errors: true,
68 retry_predicate: None,
69 }
70 }
71}
72
73impl RetryConfig {
74 pub fn no_retries() -> Self {
79 Self {
80 max_retries: 0,
81 ..Default::default()
82 }
83 }
84
85 pub fn conservative() -> Self {
91 Self {
92 max_retries: 2,
93 retry_server_errors: false,
94 ..Default::default()
95 }
96 }
97}
98
99#[derive(Clone)]
156pub struct ClientConfig {
157 pub timeout: Duration,
165
166 pub connect_timeout: Duration,
173
174 pub retry_config: RetryConfig,
181
182 pub max_connections: usize,
189
190 pub pool_idle_timeout: Duration,
197
198 pub api_key: Option<ApiKey>,
205
206 pub endpoint: crate::Endpoint,
234}
235
236impl Default for ClientConfig {
237 fn default() -> Self {
238 Self {
239 timeout: Duration::from_secs(30),
240 connect_timeout: Duration::from_secs(10),
241 retry_config: RetryConfig::default(),
242 max_connections: 20,
243 pool_idle_timeout: Duration::from_secs(90),
244 api_key: None,
245 endpoint: crate::Endpoint::public_v2(),
246 }
247 }
248}
249
250impl std::fmt::Debug for ClientConfig {
251 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252 f.debug_struct("ClientConfig")
253 .field("timeout", &self.timeout)
254 .field("connect_timeout", &self.connect_timeout)
255 .field("retry_config", &self.retry_config)
256 .field("max_connections", &self.max_connections)
257 .field("pool_idle_timeout", &self.pool_idle_timeout)
258 .field("api_key", &self.api_key)
259 .field("endpoint", &self.endpoint)
260 .finish()
261 }
262}
263
264impl ClientConfig {
265 pub fn no_retries() -> Self {
269 Self {
270 retry_config: RetryConfig::no_retries(),
271 ..Default::default()
272 }
273 }
274
275 pub fn conservative() -> Self {
279 Self {
280 retry_config: RetryConfig::conservative(),
281 ..Default::default()
282 }
283 }
284}
285
286#[derive(Debug, Clone)]
288pub struct OdosHttpClient {
289 client: Client,
290 config: ClientConfig,
291}
292
293impl OdosHttpClient {
294 pub fn new() -> Result<Self> {
296 Self::with_config(ClientConfig::default())
297 }
298
299 pub fn with_config(config: ClientConfig) -> Result<Self> {
301 let client = Client::builder()
302 .timeout(config.timeout)
303 .connect_timeout(config.connect_timeout)
304 .pool_max_idle_per_host(config.max_connections)
305 .pool_idle_timeout(config.pool_idle_timeout)
306 .build()
307 .map_err(OdosError::Http)?;
308
309 Ok(Self { client, config })
310 }
311
312 #[instrument(skip(self, request_builder_fn), level = "debug")]
314 pub async fn execute_with_retry<F>(&self, request_builder_fn: F) -> Result<Response>
315 where
316 F: Fn() -> RequestBuilder + Clone,
317 {
318 let initial_backoff_duration =
319 Duration::from_millis(self.config.retry_config.initial_backoff_ms);
320
321 let backoff = ExponentialBuilder::default()
324 .with_min_delay(initial_backoff_duration)
325 .with_max_delay(Duration::from_secs(30)) .with_max_times(self.config.retry_config.max_retries as usize + 1); let mut backoff_iter = backoff.build();
329 let mut attempt = 0;
330
331 loop {
332 attempt += 1;
333
334 let request = match request_builder_fn().build() {
335 Ok(req) => req,
336 Err(e) => return Err(OdosError::Http(e)),
337 };
338
339 let last_error = match timeout(self.config.timeout, self.client.execute(request)).await
340 {
341 Ok(Ok(response)) if response.status().is_success() => {
342 return Ok(response);
343 }
344 Ok(Ok(response)) => {
345 let status = response.status();
346
347 if status == StatusCode::TOO_MANY_REQUESTS {
348 let retry_after = extract_retry_after(&response);
349
350 let parsed = parse_error_response(response).await;
352
353 let error = OdosError::rate_limit_error_with_retry_after_and_trace(
354 parsed.message,
355 retry_after,
356 parsed.code,
357 parsed.trace_id,
358 );
359
360 if !self.should_retry(&error, attempt) {
362 return Err(error);
363 }
364
365 if let Some(delay) = retry_after {
366 if !delay.is_zero() {
368 debug!(
369 error_type = "rate_limit",
370 attempt,
371 retry_after_secs = delay.as_secs(),
372 action = "sleeping",
373 "Rate limit hit, sleeping before retry"
374 );
375 tokio::time::sleep(delay).await;
376 continue;
377 }
378 }
379 error
380 } else {
381 let parsed = parse_error_response(response).await;
383
384 let error = OdosError::api_error_with_code(
385 status,
386 parsed.message,
387 parsed.code,
388 parsed.trace_id,
389 );
390
391 if !self.should_retry(&error, attempt) {
392 return Err(error);
393 }
394
395 error
396 }
397 }
398 Ok(Err(e)) => {
399 let is_timeout = e.is_timeout();
400 let is_connect = e.is_connect();
401 let error = OdosError::Http(e);
402
403 if !self.should_retry(&error, attempt) {
404 return Err(error);
405 }
406 debug!(
407 error_type = "http_error",
408 attempt,
409 error = %error,
410 is_timeout,
411 is_connect,
412 "HTTP error occurred, will retry with backoff"
413 );
414 error
415 }
416 Err(_) => {
417 let error = OdosError::timeout_error("Request timed out");
418 debug!(
419 error_type = "timeout",
420 attempt,
421 timeout_secs = self.config.timeout.as_secs(),
422 "Request timed out, will retry with backoff"
423 );
424 error
425 }
426 };
427
428 if attempt >= self.config.retry_config.max_retries {
430 return Err(last_error);
431 }
432
433 if let Some(delay) = backoff_iter.next() {
435 tokio::time::sleep(delay).await;
436 } else {
437 return Err(last_error);
439 }
440 }
441 }
442
443 pub fn inner(&self) -> &Client {
445 &self.client
446 }
447
448 pub fn config(&self) -> &ClientConfig {
450 &self.config
451 }
452
453 fn should_retry(&self, error: &OdosError, attempts: u32) -> bool {
471 let retry_config = &self.config.retry_config;
472
473 if attempts >= retry_config.max_retries {
475 return false;
476 }
477
478 if let Some(predicate) = retry_config.retry_predicate {
480 return predicate(error);
481 }
482
483 match error {
485 OdosError::RateLimit { .. } => false,
487
488 OdosError::Api { status, .. } if status.is_client_error() => false,
490
491 OdosError::Api { status, .. } if status.is_server_error() => {
493 retry_config.retry_server_errors
494 }
495
496 OdosError::Http(err) => err.is_timeout() || err.is_connect() || err.is_request(),
498
499 OdosError::Timeout(_) => true,
501
502 _ => false,
504 }
505 }
506}
507
508fn extract_retry_after(response: &Response) -> Option<Duration> {
510 response
511 .headers()
512 .get("retry-after")
513 .and_then(|v| v.to_str().ok())
514 .and_then(|s| s.parse::<u64>().ok())
515 .map(Duration::from_secs)
516}
517
518#[derive(Debug, Clone)]
520struct ParsedErrorResponse {
521 message: String,
523 code: OdosErrorCode,
525 trace_id: Option<crate::error_code::TraceId>,
527}
528
529async fn parse_error_response(response: Response) -> ParsedErrorResponse {
535 let body_text = match response.text().await {
537 Ok(text) => text,
538 Err(e) => {
539 return ParsedErrorResponse {
540 message: format!("Failed to read response body: {}", e),
541 code: OdosErrorCode::Unknown(0),
542 trace_id: None,
543 }
544 }
545 };
546
547 match serde_json::from_str::<OdosApiErrorResponse>(&body_text) {
549 Ok(error_response) => {
550 let error_code = OdosErrorCode::from(error_response.error_code);
552 ParsedErrorResponse {
553 message: error_response.detail,
554 code: error_code,
555 trace_id: Some(error_response.trace_id),
556 }
557 }
558 Err(_) => {
559 ParsedErrorResponse {
561 message: body_text,
562 code: OdosErrorCode::Unknown(0),
563 trace_id: None,
564 }
565 }
566 }
567}
568
569impl Default for OdosHttpClient {
570 fn default() -> Self {
582 Self::new().expect("Failed to create default HTTP client")
583 }
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589 use std::sync::{Arc, Mutex};
590 use std::time::Duration;
591 use wiremock::{
592 matchers::{method, path},
593 Mock, MockServer, Request, ResponseTemplate,
594 };
595
596 fn create_retry_mock(
598 first_status: u16,
599 first_body: String,
600 success_after: usize,
601 ) -> impl Fn(&Request) -> ResponseTemplate {
602 let attempt_count = Arc::new(Mutex::new(0));
603 move |_req: &Request| {
604 let mut count = attempt_count.lock().unwrap();
605 *count += 1;
606
607 if *count < success_after {
608 ResponseTemplate::new(first_status).set_body_string(&first_body)
609 } else {
610 ResponseTemplate::new(200).set_body_string("Success")
611 }
612 }
613 }
614
615 fn create_test_client(max_retries: u32, timeout_ms: u64) -> OdosHttpClient {
617 let config = ClientConfig {
618 timeout: Duration::from_millis(timeout_ms),
619 retry_config: RetryConfig {
620 max_retries,
621 initial_backoff_ms: 10,
622 ..Default::default()
623 },
624 ..Default::default()
625 };
626 OdosHttpClient::with_config(config).unwrap()
627 }
628
629 #[test]
630 fn test_client_config_default() {
631 let config = ClientConfig::default();
632 assert_eq!(config.timeout, Duration::from_secs(30));
633 assert_eq!(config.retry_config.max_retries, 3);
634 assert_eq!(config.max_connections, 20);
635 }
636
637 #[tokio::test]
638 async fn test_client_creation() {
639 let client = OdosHttpClient::new();
640 assert!(client.is_ok());
641 }
642
643 #[tokio::test]
644 async fn test_client_with_custom_config() {
645 let config = ClientConfig {
646 timeout: Duration::from_secs(60),
647 retry_config: RetryConfig {
648 max_retries: 5,
649 ..Default::default()
650 },
651 ..Default::default()
652 };
653 let client = OdosHttpClient::with_config(config.clone());
654 assert!(client.is_ok());
655
656 let client = client.unwrap();
657 assert_eq!(client.config().timeout, Duration::from_secs(60));
658 assert_eq!(client.config().retry_config.max_retries, 5);
659 }
660
661 #[tokio::test]
662 async fn test_rate_limit_with_retry_after() {
663 let mock_server = MockServer::start().await;
664
665 Mock::given(method("GET"))
667 .and(path("/test"))
668 .respond_with(
669 ResponseTemplate::new(429)
670 .set_body_string("Rate limit exceeded")
671 .insert_header("retry-after", "1"),
672 )
673 .expect(1) .mount(&mock_server)
675 .await;
676
677 let client = create_test_client(3, 30000);
678 let response = client
679 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
680 .await;
681
682 assert!(
684 response.is_err(),
685 "Rate limit should return error immediately"
686 );
687
688 if let Err(OdosError::RateLimit {
689 message,
690 retry_after,
691 ..
692 }) = response
693 {
694 assert!(message.contains("Rate limit"));
695 assert_eq!(retry_after, Some(Duration::from_secs(1)));
696 } else {
697 panic!("Expected RateLimit error, got: {response:?}");
698 }
699 }
700
701 #[tokio::test]
702 async fn test_rate_limit_without_retry_after() {
703 let mock_server = MockServer::start().await;
704
705 Mock::given(method("GET"))
707 .and(path("/test"))
708 .respond_with(ResponseTemplate::new(429).set_body_string("Rate limit exceeded"))
709 .expect(1) .mount(&mock_server)
711 .await;
712
713 let client = create_test_client(3, 30000);
714 let response = client
715 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
716 .await;
717
718 assert!(
720 response.is_err(),
721 "Rate limit should return error immediately"
722 );
723
724 if let Err(OdosError::RateLimit {
725 message,
726 retry_after,
727 ..
728 }) = response
729 {
730 assert!(message.contains("Rate limit"));
731 assert_eq!(retry_after, None);
732 } else {
733 panic!("Expected RateLimit error, got: {response:?}");
734 }
735 }
736
737 #[tokio::test]
738 async fn test_non_retryable_error() {
739 let mock_server = MockServer::start().await;
740
741 Mock::given(method("GET"))
743 .and(path("/test"))
744 .respond_with(ResponseTemplate::new(400).set_body_string("Bad request"))
745 .expect(1)
746 .mount(&mock_server)
747 .await;
748
749 let client = OdosHttpClient::with_config(ClientConfig::default()).unwrap();
750
751 let response = client
752 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
753 .await;
754
755 assert!(response.is_err());
757 if let Err(e) = response {
758 assert!(!e.is_retryable());
759 }
760 }
761
762 #[tokio::test]
763 async fn test_retry_exhaustion_returns_last_error() {
764 let mock_server = MockServer::start().await;
765
766 Mock::given(method("GET"))
768 .and(path("/test"))
769 .respond_with(ResponseTemplate::new(503).set_body_string("Service unavailable"))
770 .mount(&mock_server)
771 .await;
772
773 let client = create_test_client(2, 30000);
774
775 let response = client
776 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
777 .await;
778
779 assert!(response.is_err());
781 if let Err(e) = response {
782 assert!(
783 matches!(e, OdosError::Api { status, .. } if status == StatusCode::SERVICE_UNAVAILABLE)
784 );
785 }
786 }
787
788 #[tokio::test]
789 async fn test_timeout_error() {
790 let mock_server = MockServer::start().await;
791
792 Mock::given(method("GET"))
794 .and(path("/test"))
795 .respond_with(
796 ResponseTemplate::new(200)
797 .set_body_string("Success")
798 .set_delay(Duration::from_secs(5)),
799 )
800 .mount(&mock_server)
801 .await;
802
803 let client = create_test_client(2, 100);
804
805 let response = client
806 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
807 .await;
808
809 assert!(response.is_err());
811 if let Err(e) = response {
812 let is_timeout = matches!(e, OdosError::Timeout(_))
814 || matches!(e, OdosError::Http(ref err) if err.is_timeout());
815 assert!(is_timeout, "Expected timeout error, got: {e:?}");
816 }
817 }
818
819 #[tokio::test]
820 async fn test_invalid_request_builder_fails_immediately() {
821 let client = OdosHttpClient::default();
822
823 let bad_builder = || {
826 let mut builder = client.inner().get("http://localhost");
827 builder = builder.header("x".repeat(100000), "value");
829 builder
830 };
831
832 let result = client.execute_with_retry(bad_builder).await;
833
834 assert!(result.is_err());
836 if let Err(e) = result {
837 assert!(matches!(e, OdosError::Http(_)));
838 }
839 }
840
841 #[tokio::test]
842 async fn test_retryable_500_error() {
843 let mock_server = MockServer::start().await;
844
845 Mock::given(method("GET"))
846 .and(path("/test"))
847 .respond_with(create_retry_mock(
848 500,
849 "Internal server error".to_string(),
850 2,
851 ))
852 .mount(&mock_server)
853 .await;
854
855 let client = create_test_client(3, 30000);
856 let response = client
857 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
858 .await;
859
860 assert!(response.is_ok(), "500 error should be retried and succeed");
861 }
862
863 #[tokio::test]
864 async fn test_retryable_502_bad_gateway() {
865 let mock_server = MockServer::start().await;
866
867 Mock::given(method("GET"))
868 .and(path("/test"))
869 .respond_with(create_retry_mock(502, "Bad gateway".to_string(), 2))
870 .mount(&mock_server)
871 .await;
872
873 let client = create_test_client(3, 30000);
874 let response = client
875 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
876 .await;
877
878 assert!(response.is_ok(), "502 error should be retried and succeed");
879 }
880
881 #[tokio::test]
882 async fn test_retryable_503_service_unavailable() {
883 let mock_server = MockServer::start().await;
884
885 Mock::given(method("GET"))
886 .and(path("/test"))
887 .respond_with(create_retry_mock(503, "Service unavailable".to_string(), 3))
888 .mount(&mock_server)
889 .await;
890
891 let client = create_test_client(3, 30000);
892 let response = client
893 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
894 .await;
895
896 assert!(response.is_ok(), "503 error should be retried and succeed");
897 }
898
899 #[tokio::test]
900 async fn test_retryable_504_gateway_timeout() {
901 let mock_server = MockServer::start().await;
902
903 Mock::given(method("GET"))
904 .and(path("/test"))
905 .respond_with(create_retry_mock(504, "Gateway timeout".to_string(), 2))
906 .mount(&mock_server)
907 .await;
908
909 let client = create_test_client(3, 30000);
910 let response = client
911 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
912 .await;
913
914 assert!(response.is_ok(), "504 error should be retried and succeed");
915 }
916
917 #[tokio::test]
918 async fn test_network_error_retryable() {
919 let client = create_test_client(2, 100);
921
922 let response = client
923 .execute_with_retry(|| client.inner().get("http://localhost:1"))
924 .await;
925
926 assert!(response.is_err());
928 if let Err(e) = response {
929 assert!(matches!(e, OdosError::Http(_)));
930 }
931 }
932
933 #[test]
934 fn test_accessor_methods() {
935 let config = ClientConfig {
936 timeout: Duration::from_secs(45),
937 retry_config: RetryConfig {
938 max_retries: 5,
939 ..Default::default()
940 },
941 ..Default::default()
942 };
943 let client = OdosHttpClient::with_config(config.clone()).unwrap();
944
945 assert_eq!(client.config().timeout, Duration::from_secs(45));
947 assert_eq!(client.config().retry_config.max_retries, 5);
948
949 let _inner: &reqwest::Client = client.inner();
951 }
952
953 #[test]
954 fn test_default_client() {
955 let client = OdosHttpClient::default();
956
957 assert_eq!(client.config().timeout, Duration::from_secs(30));
959 assert_eq!(client.config().retry_config.max_retries, 3);
960 }
961
962 #[test]
963 fn test_extract_retry_after_valid_numeric() {
964 let response = reqwest::Response::from(
965 http::Response::builder()
966 .status(429)
967 .header("retry-after", "30")
968 .body("")
969 .unwrap(),
970 );
971
972 let retry_after = extract_retry_after(&response);
973 assert_eq!(retry_after, Some(Duration::from_secs(30)));
974 }
975
976 #[test]
977 fn test_extract_retry_after_missing_header() {
978 let response =
979 reqwest::Response::from(http::Response::builder().status(429).body("").unwrap());
980
981 let retry_after = extract_retry_after(&response);
982 assert_eq!(retry_after, None);
983 }
984
985 #[test]
986 fn test_extract_retry_after_malformed_value() {
987 let response = reqwest::Response::from(
988 http::Response::builder()
989 .status(429)
990 .header("retry-after", "not-a-number")
991 .body("")
992 .unwrap(),
993 );
994
995 let retry_after = extract_retry_after(&response);
996 assert_eq!(retry_after, None);
997 }
998
999 #[test]
1000 fn test_extract_retry_after_zero_value() {
1001 let response = reqwest::Response::from(
1002 http::Response::builder()
1003 .status(429)
1004 .header("retry-after", "0")
1005 .body("")
1006 .unwrap(),
1007 );
1008
1009 let retry_after = extract_retry_after(&response);
1010 assert_eq!(retry_after, Some(Duration::from_secs(0)));
1011 }
1012
1013 #[tokio::test]
1014 async fn test_rate_limit_with_retry_after_zero() {
1015 let mock_server = MockServer::start().await;
1016
1017 Mock::given(method("GET"))
1019 .and(path("/test"))
1020 .respond_with(
1021 ResponseTemplate::new(429)
1022 .set_body_string("Rate limit exceeded")
1023 .insert_header("retry-after", "0"),
1024 )
1025 .expect(1) .mount(&mock_server)
1027 .await;
1028
1029 let client = create_test_client(3, 30000);
1030 let response = client
1031 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1032 .await;
1033
1034 assert!(
1036 response.is_err(),
1037 "Rate limit should return error immediately"
1038 );
1039
1040 if let Err(OdosError::RateLimit {
1041 message,
1042 retry_after,
1043 ..
1044 }) = response
1045 {
1046 assert!(message.contains("Rate limit"));
1047 assert_eq!(retry_after, Some(Duration::from_secs(0)));
1048 } else {
1049 panic!("Expected RateLimit error, got: {response:?}");
1050 }
1051 }
1052
1053 #[test]
1054 fn test_extract_retry_after_large_value() {
1055 let response = reqwest::Response::from(
1056 http::Response::builder()
1057 .status(429)
1058 .header("retry-after", "3600")
1059 .body("")
1060 .unwrap(),
1061 );
1062
1063 let retry_after = extract_retry_after(&response);
1064 assert_eq!(retry_after, Some(Duration::from_secs(3600)));
1065 }
1066
1067 #[test]
1068 fn test_extract_retry_after_invalid_utf8() {
1069 let response = reqwest::Response::from(
1070 http::Response::builder()
1071 .status(429)
1072 .header("retry-after", vec![0xff, 0xfe])
1073 .body("")
1074 .unwrap(),
1075 );
1076
1077 let retry_after = extract_retry_after(&response);
1078 assert_eq!(retry_after, None);
1079 }
1080
1081 #[test]
1082 fn test_client_config_debug_redacts_api_key() {
1083 use crate::ApiKey;
1084 use uuid::Uuid;
1085
1086 let uuid = Uuid::new_v4();
1087 let uuid_str = uuid.to_string();
1088 let api_key = ApiKey::new(uuid);
1089
1090 let config = ClientConfig {
1091 api_key: Some(api_key),
1092 ..Default::default()
1093 };
1094
1095 let debug_output = format!("{:?}", config);
1096
1097 assert!(debug_output.contains("[REDACTED]"));
1099
1100 assert!(
1102 !debug_output.contains(&uuid_str),
1103 "API key UUID should not appear in debug output, but found: {}",
1104 uuid_str
1105 );
1106 }
1107
1108 #[tokio::test]
1109 async fn test_max_retries_zero() {
1110 let mock_server = MockServer::start().await;
1111
1112 Mock::given(method("GET"))
1114 .and(path("/test"))
1115 .respond_with(ResponseTemplate::new(500).set_body_string("Server error"))
1116 .expect(1) .mount(&mock_server)
1118 .await;
1119
1120 let client = create_test_client(0, 30000); let response = client
1122 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1123 .await;
1124
1125 assert!(response.is_err());
1127 if let Err(e) = response {
1128 assert!(
1129 matches!(e, OdosError::Api { status, .. } if status == StatusCode::INTERNAL_SERVER_ERROR)
1130 );
1131 }
1132 }
1133
1134 #[tokio::test]
1135 async fn test_parse_structured_error_response() {
1136 use crate::error_code::OdosErrorCode;
1137
1138 let error_json = r#"{
1140 "detail": "Error getting quote, please try again",
1141 "traceId": "10becdc8-a021-4491-8201-a17b657204e0",
1142 "errorCode": 2999
1143 }"#;
1144
1145 let http_response = http::Response::builder()
1146 .status(500)
1147 .body(error_json)
1148 .unwrap();
1149 let response = reqwest::Response::from(http_response);
1150
1151 let parsed = parse_error_response(response).await;
1152
1153 assert_eq!(parsed.message, "Error getting quote, please try again");
1154 assert_eq!(parsed.code, OdosErrorCode::AlgoInternal);
1155 assert!(parsed.trace_id.is_some());
1156 assert_eq!(
1157 parsed.trace_id.unwrap().to_string(),
1158 "10becdc8-a021-4491-8201-a17b657204e0"
1159 );
1160 }
1161
1162 #[tokio::test]
1163 async fn test_parse_unstructured_error_response() {
1164 let http_response = http::Response::builder()
1166 .status(500)
1167 .body("Internal server error")
1168 .unwrap();
1169 let response = reqwest::Response::from(http_response);
1170
1171 let parsed = parse_error_response(response).await;
1172
1173 assert_eq!(parsed.message, "Internal server error");
1174 assert_eq!(parsed.code, OdosErrorCode::Unknown(0));
1175 assert!(parsed.trace_id.is_none());
1176 }
1177
1178 #[tokio::test]
1179 async fn test_api_error_with_structured_response() {
1180 let mock_server = MockServer::start().await;
1181
1182 let error_json = r#"{
1183 "detail": "Invalid chain ID",
1184 "traceId": "a0b1c2d3-e4f5-6789-0abc-def123456789",
1185 "errorCode": 4001
1186 }"#;
1187
1188 Mock::given(method("GET"))
1189 .and(path("/test"))
1190 .respond_with(ResponseTemplate::new(400).set_body_string(error_json))
1191 .expect(1)
1192 .mount(&mock_server)
1193 .await;
1194
1195 let client = create_test_client(0, 30000);
1196 let response = client
1197 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1198 .await;
1199
1200 assert!(response.is_err());
1201 if let Err(e) = response {
1202 assert!(matches!(e, OdosError::Api { .. }));
1204
1205 let error_code = e.error_code();
1207 assert!(error_code.is_some());
1208 assert!(error_code.unwrap().is_invalid_chain_id());
1209
1210 let trace_id = e.trace_id();
1212 assert!(trace_id.is_some());
1213 } else {
1214 panic!("Expected error, got success");
1215 }
1216 }
1217
1218 #[tokio::test]
1219 async fn test_client_config_failure() {
1220 let config = ClientConfig {
1223 max_connections: usize::MAX,
1224 ..Default::default()
1225 };
1226
1227 let result = OdosHttpClient::with_config(config);
1229
1230 match result {
1233 Ok(_) => {
1234 }
1236 Err(e) => {
1237 assert!(matches!(e, OdosError::Http(_)));
1239 }
1240 }
1241 }
1242
1243 #[tokio::test]
1244 async fn test_rate_limit_with_trace_id() {
1245 let mock_server = MockServer::start().await;
1246
1247 let error_json = r#"{
1248 "detail": "Rate limit exceeded",
1249 "traceId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
1250 "errorCode": 4299
1251 }"#;
1252
1253 Mock::given(method("GET"))
1254 .and(path("/test"))
1255 .respond_with(
1256 ResponseTemplate::new(429)
1257 .set_body_string(error_json)
1258 .insert_header("retry-after", "30"),
1259 )
1260 .expect(1)
1261 .mount(&mock_server)
1262 .await;
1263
1264 let client = create_test_client(0, 30000);
1265 let response = client
1266 .execute_with_retry(|| client.inner().get(format!("{}/test", mock_server.uri())))
1267 .await;
1268
1269 assert!(response.is_err());
1270 if let Err(e) = response {
1271 assert!(e.is_rate_limit());
1273
1274 let trace_id = e.trace_id();
1276 assert!(trace_id.is_some());
1277 assert_eq!(
1278 trace_id.unwrap().to_string(),
1279 "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
1280 );
1281
1282 let error_msg = e.to_string();
1284 assert!(error_msg.contains("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
1285 assert!(error_msg.contains("[trace:"));
1286 } else {
1287 panic!("Expected error, got success");
1288 }
1289 }
1290}